52708bff52
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
362 lines
20 KiB
C#
362 lines
20 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// #85 — verifies <see cref="AddressSpaceApplier.MaterialiseHierarchy"/> builds the UNS
|
|
/// Area/Line/Equipment folder tree through <see cref="IOpcUaAddressSpaceSink.EnsureFolder"/>.
|
|
/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
|
|
/// drives a real <see cref="OtOpcUaNodeManager"/> and inspects the resulting predefined-node
|
|
/// count to prove the folders land in the SDK address space.
|
|
/// </summary>
|
|
public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"otopcua-pki-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>Verifies that MaterialiseHierarchy creates areas, lines, and equipment with correct parent relationships.</summary>
|
|
[Fact]
|
|
public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
|
|
{
|
|
var sink = new RecordingFolderSink();
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var composition = new AddressSpaceComposition(
|
|
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
|
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
applier.MaterialiseHierarchy(composition);
|
|
|
|
var calls = sink.Calls;
|
|
calls.Count.ShouldBe(3);
|
|
calls[0].ShouldBe(("area-1", null, "Plant North"));
|
|
calls[1].ShouldBe(("line-1", "area-1", "Cell A"));
|
|
calls[2].ShouldBe(("eq-1", "line-1", "Pump-1"));
|
|
}
|
|
|
|
/// <summary>Verifies that orphan equipment without a parent line appears under root.</summary>
|
|
[Fact]
|
|
public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
|
|
{
|
|
var sink = new RecordingFolderSink();
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var composition = new AddressSpaceComposition(
|
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
applier.MaterialiseHierarchy(composition);
|
|
|
|
sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
|
|
}
|
|
|
|
/// <summary>Verifies that MaterialiseHierarchy creates folder nodes in a real SDK node manager.</summary>
|
|
[Fact]
|
|
public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
|
|
{
|
|
await using var host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.Hierarchy",
|
|
ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
var sdkServer = new OtOpcUaSdkServer();
|
|
await host.StartAsync(sdkServer, Ct);
|
|
sdkServer.NodeManager.ShouldNotBeNull();
|
|
|
|
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
applier.MaterialiseHierarchy(new AddressSpaceComposition(
|
|
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
|
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
|
|
|
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
|
|
|
|
// Idempotent: re-applying with the same composition doesn't create duplicates.
|
|
applier.MaterialiseHierarchy(new AddressSpaceComposition(
|
|
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
|
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
|
|
|
|
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
|
|
}
|
|
|
|
/// <summary>Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
|
|
/// applying the same composition twice yields a single Variable node (no duplicates). This is the
|
|
/// restart-safety guarantee — HandleRebuild runs on both the apply path and the
|
|
/// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
|
|
/// restart re-runs this pass and must not double-materialise.</summary>
|
|
[Fact]
|
|
public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
|
|
{
|
|
await using var host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.EquipmentTags",
|
|
ApplicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
var sdkServer = new OtOpcUaSdkServer();
|
|
await host.StartAsync(sdkServer, Ct);
|
|
sdkServer.NodeManager.ShouldNotBeNull();
|
|
|
|
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
var composition = new AddressSpaceComposition(
|
|
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
|
UnsLines: Array.Empty<UnsLineProjection>(),
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
|
|
{
|
|
EquipmentTags = new[]
|
|
{
|
|
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
|
},
|
|
};
|
|
|
|
// Equipment folder first (the variable's parent), then the tag pass — applied twice.
|
|
applier.MaterialiseHierarchy(composition);
|
|
applier.MaterialiseEquipmentTags(composition);
|
|
applier.MaterialiseEquipmentTags(composition);
|
|
|
|
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full v3 structure-materialisation pipeline against a real SDK node manager: real Config entities
|
|
/// (RawFolder → Driver → Device → Tag, plus Area / Line / Equipment and a <c>UnsTagReference</c>) →
|
|
/// <see cref="AddressSpaceComposer.Compose"/> → MaterialiseHierarchy + MaterialiseRawSubtree +
|
|
/// MaterialiseUnsReferences → <see cref="OtOpcUaNodeManager"/>. Proves the <b>dual-namespace</b>
|
|
/// shape lands in a live OPC UA address space: the raw device tree AND the UNS folder tree, with the
|
|
/// raw tag's variable projected into its referencing equipment as a second variable.
|
|
/// <para>Revived from the <c>EquipmentTagsDarkBatch4</c> skip. It was parked pending "the Batch-4
|
|
/// UnsTagReference fan-out", which shipped as v3.0 — but the equipment-tag path it was written
|
|
/// against was RETIRED rather than lit, so unskipping alone would have failed. Rewritten to the
|
|
/// shape that actually replaced it. The sibling <c>MaterialiseHierarchy_against_real_SDK</c> still
|
|
/// covers folders only; this is the layer's only real-SDK proof that variables materialise in both
|
|
/// namespaces.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Raw_and_uns_variables_materialise_end_to_end_against_real_SDK()
|
|
{
|
|
await using var host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.EquipmentE2e",
|
|
ApplicationUri = $"urn:OtOpcUa.EquipmentE2e:{Guid.NewGuid():N}",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
var sdkServer = new OtOpcUaSdkServer();
|
|
await host.StartAsync(sdkServer, Ct);
|
|
sdkServer.NodeManager.ShouldNotBeNull();
|
|
|
|
// The v3 raw chain: RawFolder "Plant" → Driver "Modbus" → Device "dev1" → Tag "Speed"
|
|
// (RawPath Plant/Modbus/dev1/Speed), plus the UNS tree it is projected into.
|
|
var folder = new RawFolder { RawFolderId = "rf-plant", ClusterId = "c1", Name = "Plant", ParentRawFolderId = null };
|
|
var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-plant" };
|
|
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "dev1", DeviceConfig = "{}" };
|
|
var speed = new Tag
|
|
{
|
|
TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = null, Name = "Speed", DataType = "Float",
|
|
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
|
};
|
|
var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
|
|
var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
|
|
var equipment = new Equipment { EquipmentId = "eq-1", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
|
|
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-speed", DisplayNameOverride = null };
|
|
|
|
var composition = AddressSpaceComposer.Compose(
|
|
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
|
|
Array.Empty<ScriptedAlarm>(),
|
|
unsTagReferences: new[] { reference }, tags: new[] { speed },
|
|
rawFolders: new[] { folder }, devices: new[] { device });
|
|
|
|
// Compose side: one raw tag at its RawPath, projected once into the referencing equipment.
|
|
var rawTag = composition.RawTags.ShouldHaveSingleItem();
|
|
rawTag.NodeId.ShouldBe("Plant/Modbus/dev1/Speed");
|
|
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
|
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
|
|
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
|
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev1/Speed");
|
|
|
|
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
applier.MaterialiseHierarchy(composition);
|
|
applier.MaterialiseRawSubtree(composition);
|
|
applier.MaterialiseUnsReferences(composition);
|
|
|
|
// UNS: filling + line-1 + station-1. Raw: the Plant folder + the Modbus driver + the dev1 device.
|
|
sdkServer.NodeManager!.FolderCount.ShouldBe(6);
|
|
// ONE source value, TWO nodes — the raw tag's variable and its UNS projection. This is the
|
|
// single-source-fan-out shape; a regression that dropped either namespace shows up here as 1.
|
|
sdkServer.NodeManager!.VariableCount.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's
|
|
/// DisplayName IN PLACE against a real SDK node manager (no rebuild, no node count change). Proves the
|
|
/// full chain: planner emits a RenamedFolders delta → applier drives the surgical
|
|
/// SdkAddressSpaceSink.UpdateFolderDisplayName → OtOpcUaNodeManager mutates the live FolderState +
|
|
/// ClearChangeMasks. Before the fix, EnsureFolder early-returned on the existing folder and the
|
|
/// DisplayName stayed stale until a full RebuildAddressSpace.</summary>
|
|
[Fact]
|
|
public async Task Area_and_line_rename_updates_existing_folder_display_name_in_place_against_real_SDK()
|
|
{
|
|
await using var host = new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.FolderRename",
|
|
ApplicationUri = $"urn:OtOpcUa.FolderRename:{Guid.NewGuid():N}",
|
|
OpcUaPort = AllocateFreePort(),
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
|
|
var sdkServer = new OtOpcUaSdkServer();
|
|
await host.StartAsync(sdkServer, Ct);
|
|
sdkServer.NodeManager.ShouldNotBeNull();
|
|
var nm = sdkServer.NodeManager!;
|
|
|
|
var sink = new SdkAddressSpaceSink(nm);
|
|
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
|
|
// Materialise the initial hierarchy (area + line + equipment) with the OLD display names.
|
|
var initial = new AddressSpaceComposition(
|
|
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
|
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
|
applier.MaterialiseHierarchy(initial);
|
|
|
|
nm.FolderCount.ShouldBe(3);
|
|
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant North");
|
|
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell A");
|
|
|
|
// Now a rename-only deploy: same ids, new display names on BOTH the area and the line.
|
|
var renamed = new AddressSpaceComposition(
|
|
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") },
|
|
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
|
|
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
|
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
|
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
|
|
|
var plan = AddressSpacePlanner.Compute(initial, renamed);
|
|
plan.IsEmpty.ShouldBeFalse(); // rename is no longer a silent no-op
|
|
plan.RenamedFolders.Count.ShouldBe(2);
|
|
|
|
var outcome = applier.Apply(plan);
|
|
|
|
// In-place: no rebuild, no node-count change — only the DisplayNames were swapped.
|
|
outcome.RebuildCalled.ShouldBeFalse();
|
|
nm.FolderCount.ShouldBe(3);
|
|
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant South");
|
|
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell B");
|
|
// The unrelated equipment folder is untouched.
|
|
nm.TryGetFolder("eq-1")!.DisplayName.Text.ShouldBe("Pump-1");
|
|
}
|
|
|
|
private static int AllocateFreePort()
|
|
{
|
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
|
|
/// <summary>Disposes of resources allocated by this test class.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink
|
|
{
|
|
private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new();
|
|
/// <summary>Gets the list of EnsureFolder calls recorded by this sink.</summary>
|
|
public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
|
|
|
|
/// <summary>Records a value write (stub implementation for testing).</summary>
|
|
/// <param name="nodeId">The node ID of the variable.</param>
|
|
/// <param name="value">The value to write.</param>
|
|
/// <param name="quality">The OPC UA quality value.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
|
/// <summary>Records an alarm condition write (stub implementation for testing).</summary>
|
|
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
|
|
/// <param name="state">The full condition state snapshot.</param>
|
|
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
|
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
|
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
|
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
|
|
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
|
|
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
|
|
/// <param name="displayName">The condition display name.</param>
|
|
/// <param name="alarmType">The domain alarm type.</param>
|
|
/// <param name="severity">The domain severity.</param>
|
|
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
|
/// <summary>Records a folder creation request.</summary>
|
|
/// <param name="folderNodeId">The node ID of the folder.</param>
|
|
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
|
/// <param name="displayName">The display name of the folder.</param>
|
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
|
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
|
|
/// <summary>Ensures a variable exists (stub implementation for testing).</summary>
|
|
/// <param name="variableNodeId">The node ID of the variable.</param>
|
|
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
|
|
/// <param name="displayName">The display name of the variable.</param>
|
|
/// <param name="dataType">The OPC UA built-in type name.</param>
|
|
/// <param name="writable">Whether the node is created read/write.</param>
|
|
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
|
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
|
public void RebuildAddressSpace() { }
|
|
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
|
|
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
|
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
|
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
|
}
|
|
}
|