feat(twincat): 1-D array symbol read via ADS + IsArray discovery

This commit is contained in:
Joseph Doherty
2026-06-16 21:59:17 -04:00
parent 950069392c
commit 3e74239532
7 changed files with 341 additions and 14 deletions
@@ -70,13 +70,19 @@ public sealed record TwinCATDeviceOptions(
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
/// </summary>
/// <param name="ArrayLength">
/// When non-null, this tag is a 1-D array of <paramref name="ArrayLength"/> elements of
/// <paramref name="DataType"/>. Drives <c>IsArray</c>/<c>ArrayDim</c> at discovery and a
/// native ADS array read at runtime (Phase 4c). <c>null</c> = scalar (the default).
/// </param>
public sealed record TwinCATTagDefinition(
string Name,
string DeviceHostAddress,
string SymbolPath,
TwinCATDataType DataType,
bool Writable = true,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int? ArrayLength = null);
/// <summary>Probe options for TwinCAT connection monitoring.</summary>
public sealed class TwinCATProbeOptions
@@ -29,9 +29,14 @@ public static class TwinCATEquipmentTagParser
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
var dataType = ReadEnum(root, "dataType", TwinCATDataType.DInt);
// Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
// `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
// is true AND it is a positive JSON number, so a stale length behind a cleared
// isArray never produces an orphan array tag (Phase 4c).
var arrayLength = ReadArrayLength(root);
def = new TwinCATTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath,
DataType: dataType, Writable: true);
DataType: dataType, Writable: true, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
@@ -46,4 +51,18 @@ public static class TwinCATEquipmentTagParser
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? "" : "";
/// <summary>
/// Reads the optional 1-D array length: <c>arrayLength</c> (a positive uint) honoured ONLY
/// when <c>isArray</c> is the JSON literal <c>true</c>. Returns <c>null</c> (scalar) when
/// isArray is absent/false, when arrayLength is absent / non-numeric / zero / negative.
/// </summary>
private static int? ReadArrayLength(JsonElement o)
{
if (!o.TryGetProperty("isArray", out var aEl) || aEl.ValueKind != JsonValueKind.True)
return null;
if (!o.TryGetProperty("arrayLength", out var lEl) || lEl.ValueKind != JsonValueKind.Number)
return null;
return lEl.TryGetInt32(out var len) && len > 0 ? len : null;
}
}
@@ -97,12 +97,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// <param name="symbolPath">The ADS symbol path to read from.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="bitIndex">Optional bit index for BOOL values within larger containers.</param>
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/>
/// elements; the boxed value is the element-typed CLR array (e.g. <c>int[]</c>). Phase 4c.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the value and OPC UA status code.</returns>
public async Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
CancellationToken cancellationToken)
{
try
@@ -112,7 +115,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// container as its widest unsigned primitive and extract the bit locally. The
// .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off
// first. uint covers WORD / DWORD containers; BYTE-sized bit containers are
// rare in real code and promoting to uint is harmless for them.
// rare in real code and promoting to uint is harmless for them. Bit-within-word
// is inherently scalar — arrayCount does not apply.
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
var parent = StripBitSuffix(symbolPath);
@@ -123,6 +127,25 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
}
// Array read — ADS reads array symbols natively into a typed managed array. We ask
// the SDK for the element-CLR-type's 1-D array (e.g. int[]); the .NET binding
// marshals the whole array in one read. The returned value is already the boxed
// element-typed array (int[] / float[] / bool[] / string[] / …), so the runtime's
// OPC UA layer sees a proper 1-D array value. ASSUMPTION (no live ADS here):
// AdsClient.ReadValueAsync(symbol, elementType.MakeArrayType(), ct) returns the
// typed array — this matches Beckhoff's documented generic ReadValue<T[]> surface
// (InfoSys tcadsnetref / SumRead samples). Verified only against the fake client.
if (arrayCount is int count && count > 0)
{
var elementType = MapToClrType(type);
var arrayType = elementType.MakeArrayType();
var arrResult = await _client.ReadValueAsync(symbolPath, arrayType, cancellationToken)
.ConfigureAwait(false);
if (arrResult.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)arrResult.ErrorCode));
return (arrResult.Value, TwinCATStatusMapper.Good);
}
var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
.ConfigureAwait(false);
@@ -313,12 +336,49 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// distinctly from a genuine browse failure; a yield break would let a partial
// symbol set appear as a fully successful discovery (Driver.TwinCAT-010).
cancellationToken.ThrowIfCancellationRequested();
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
var (mapped, arrayLength) = MapSymbolType(symbol.DataType);
var readOnly = !IsSymbolWritable(symbol);
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly, arrayLength);
}
}
/// <summary>
/// Resolves a symbol's <see cref="IDataType"/> to the driver's atomic
/// <see cref="TwinCATDataType"/> plus an optional 1-D array length.
/// <para>
/// A 1-D array symbol (<c>Category == Array</c>, <see cref="IArrayType.Dimensions"/> count 1)
/// surfaces as its ELEMENT type with the dimension's <see cref="IDimension.ElementCount"/> as
/// the array length. ASSUMPTION (no live ADS here): the array <see cref="IDataType"/> exposes
/// <see cref="IArrayType"/> with <see cref="IArrayType.ElementType"/> + a
/// <see cref="IArrayType.Dimensions"/> collection whose single <see cref="IDimension"/> carries
/// <see cref="IDimension.ElementCount"/> — matches the TwinCAT TypeSystem surface
/// (<c>TwinCAT.TypeSystem.IArrayType</c> / <c>IDimensionCollection.GetDimensionLengths()</c>).
/// Verified by unit test against the fake client only.
/// </para>
/// Multi-dimensional arrays (Dimensions.Count > 1, including jagged) are NOT in scope for
/// Phase 4c — they fall through as a scalar/unsupported <c>null</c> (which DiscoverAsync drops),
/// never silently mis-reported as a 1-D array.
/// </summary>
private static (TwinCATDataType? type, int? arrayLength) MapSymbolType(IDataType? dataType)
{
if (dataType is null) return (null, null);
if (dataType.Category == DataTypeCategory.Array && dataType is IArrayType arr)
{
// 1-D only. A multi-dim / jagged array is out of atomic scope → drop (null).
var dims = arr.Dimensions;
if (arr.IsJagged || dims is null || dims.Count != 1) return (null, null);
var element = MapSymbolTypeName(arr.ElementType?.Name);
if (element is null) return (null, null); // element type is itself unsupported (UDT array etc.)
var length = dims[0].ElementCount;
return length > 0 ? (element, length) : (null, null);
}
return (MapSymbolTypeName(dataType.Name), null);
}
private static TwinCATDataType? MapSymbolTypeName(string? typeName)
{
if (typeName is null) return null;
@@ -40,11 +40,15 @@ public interface ITwinCATClient : IDisposable
/// <param name="symbolPath">The ADS symbol path.</param>
/// <param name="type">The target data type.</param>
/// <param name="bitIndex">Optional bit index for bit extraction within a word.</param>
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/>
/// elements; the boxed value is the element-typed CLR array (<c>int[]</c> / <c>float[]</c> /
/// <c>bool[]</c> / <c>string[]</c> / …). When null, read a scalar (Phase 4c).</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
CancellationToken cancellationToken);
/// <summary>
@@ -113,13 +117,19 @@ public interface ITwinCATNotificationHandle : IDisposable { }
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
/// </summary>
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's type
/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).</param>
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/> of the (element) type; <c>null</c>
/// when the symbol's type doesn't map onto our supported atomic surface (UDTs, pointers,
/// function blocks). For an array symbol this is the ELEMENT type, with <paramref name="ArrayLength"/>
/// carrying the dimension.</param>
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
/// <param name="ArrayLength">When non-null, the symbol is a 1-D array of this many
/// <paramref name="DataType"/> elements. <c>null</c> = scalar. Multi-dimensional arrays are
/// reported as <c>null</c> (treated as scalar/unsupported) — only 1-D is in scope for Phase 4c.</param>
public sealed record TwinCATDiscoveredSymbol(
string InstancePath,
TwinCATDataType? DataType,
bool ReadOnly);
bool ReadOnly,
int? ArrayLength = null);
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
public interface ITwinCATClientFactory
@@ -227,8 +227,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
// An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read;
// the boxed result is an element-typed CLR array. Scalar tags pass null
// (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores
// arrayCount when a bitIndex is present (Phase 4c).
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == TwinCATStatusMapper.Good)
@@ -340,8 +345,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
// A pre-declared tag with a positive ArrayLength is a 1-D array node; null
// (or non-positive) stays scalar (Phase 4c).
IsArray: tag.ArrayLength is > 0,
ArrayDim: tag.ArrayLength is > 0 ? (uint)tag.ArrayLength.Value : null,
SecurityClass: tag.Writable
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
@@ -367,8 +374,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
FullName: sym.InstancePath,
DriverDataType: dt.ToDriverDataType(),
IsArray: false,
ArrayDim: null,
// A discovered 1-D array symbol carries its element count in
// sym.ArrayLength (the browser reports the ELEMENT type as dt);
// multi-dim/unsupported arrays arrive with null ArrayLength → scalar
// (Phase 4c).
IsArray: sym.ArrayLength is > 0,
ArrayDim: sym.ArrayLength is > 0 ? (uint)sym.ArrayLength.Value : null,
SecurityClass: sym.ReadOnly
? SecurityClassification.ViewOnly
: SecurityClassification.Operate,
@@ -27,6 +27,10 @@ internal class FakeTwinCATClient : ITwinCATClient
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the write statuses by symbol path.</summary>
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Records the <c>arrayCount</c> argument of the most recent <see cref="ReadValueAsync"/>
/// call (null = scalar read) so Phase 4c array-read tests can assert the driver requested
/// the array with the authored length.</summary>
public int? LastReadArrayCount { get; private set; }
/// <summary>Gets the log of all write operations.</summary>
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
/// <summary>Gets or sets the result returned by ProbeAsync.</summary>
@@ -55,12 +59,14 @@ internal class FakeTwinCATClient : ITwinCATClient
/// <param name="symbolPath">The path to the symbol to read.</param>
/// <param name="type">The data type of the symbol.</param>
/// <param name="bitIndex">The optional bit index for bit-level reads.</param>
/// <param name="arrayCount">The optional 1-D array element count (null = scalar read).</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the simulated value and status.</returns>
public virtual Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
LastReadArrayCount = arrayCount;
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status));
@@ -0,0 +1,215 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Phase 4c — 1-D array support for the TwinCAT (ADS) driver. Covers the two discovery
/// hard-wire flips (pre-declared + discovered symbols now report IsArray/ArrayDim from the
/// ADS symbol's array dimension), the array READ path (the equipment tag's arrayLength drives
/// a native array read boxed into a typed CLR array), and the equipment-tag parser threading
/// <c>arrayLength</c> from the TagConfig JSON.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATArraySupportTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
// ---- (1) discovery: pre-declared array tag flips IsArray/ArrayDim ----
/// <summary>A pre-declared tag carrying an ArrayLength surfaces as a 1-D array node.</summary>
[Fact]
public async Task Predeclared_array_tag_reports_IsArray_and_ArrayDim()
{
var builder = new RecordingBuilder();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags =
[
new TwinCATTagDefinition("Speeds", Host, "MAIN.Speeds", TwinCATDataType.DInt,
Writable: true, WriteIdempotent: false, ArrayLength: 8),
],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
}, "drv-1", new FakeTwinCATClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
var v = builder.Variables.Single(x => x.BrowseName == "Speeds").Info;
v.IsArray.ShouldBeTrue();
v.ArrayDim.ShouldBe(8u);
}
/// <summary>A scalar pre-declared tag still reports IsArray=false / ArrayDim=null.</summary>
[Fact]
public async Task Predeclared_scalar_tag_stays_scalar()
{
var builder = new RecordingBuilder();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
var v = builder.Variables.Single(x => x.BrowseName == "Speed").Info;
v.IsArray.ShouldBeFalse();
v.ArrayDim.ShouldBeNull();
}
// ---- (1) discovery: discovered (browsed) array symbol flips IsArray/ArrayDim ----
/// <summary>A discovered (browsed) 1-D array symbol surfaces as a 1-D array node.</summary>
[Fact]
public async Task Discovered_array_symbol_reports_IsArray_and_ArrayDim()
{
var builder = new RecordingBuilder();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
c.BrowseResults.Add(new TwinCATDiscoveredSymbol(
"MAIN.Buffer", TwinCATDataType.Int, ReadOnly: false, ArrayLength: 16));
c.BrowseResults.Add(new TwinCATDiscoveredSymbol(
"GVL.Scalar", TwinCATDataType.Real, ReadOnly: false));
return c;
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
var arr = builder.Variables.Single(x => x.Info.FullName == "MAIN.Buffer").Info;
arr.IsArray.ShouldBeTrue();
arr.ArrayDim.ShouldBe(16u);
var scalar = builder.Variables.Single(x => x.Info.FullName == "GVL.Scalar").Info;
scalar.IsArray.ShouldBeFalse();
scalar.ArrayDim.ShouldBeNull();
}
// ---- (2) read path: array read returns a typed CLR array ----
/// <summary>An equipment array tag reads a typed CLR array (boxed as object) through the driver.</summary>
[Fact]
public async Task Driver_reads_an_array_equipment_tag_as_typed_clr_array()
{
var json =
$$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}""";
var expected = new[] { 10, 20, 30, 40 };
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speeds"] = expected } },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-arr", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].Value.ShouldBeOfType<int[]>().ShouldBe(expected);
// The driver must request the array read with the authored length.
factory.Clients[0].LastReadArrayCount.ShouldBe(4);
}
/// <summary>A scalar equipment tag reads with a null array count (scalar path unchanged).</summary>
[Fact]
public async Task Driver_reads_a_scalar_equipment_tag_with_null_array_count()
{
var json = $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 7 } },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-scalar", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].Value.ShouldBe(7);
factory.Clients[0].LastReadArrayCount.ShouldBeNull();
}
// ---- (3) resolver: equipment-tag parser threads arrayLength ----
/// <summary>The equipment-tag parser threads <c>arrayLength</c> into the transient definition.</summary>
[Fact]
public void Parser_threads_arrayLength_into_the_definition()
{
var json =
"""{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}""";
TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBe(12);
}
/// <summary>A blob without isArray (or with isArray=false) leaves ArrayLength null.</summary>
[Fact]
public void Parser_leaves_ArrayLength_null_for_a_scalar_blob()
{
TwinCATEquipmentTagParser.TryParse(
"""{"symbolPath":"MAIN.X","dataType":"DInt"}""", out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
/// <summary>arrayLength is ignored when isArray is absent/false (no orphan length).</summary>
[Fact]
public void Parser_ignores_arrayLength_when_isArray_is_false()
{
TwinCATEquipmentTagParser.TryParse(
"""{"symbolPath":"MAIN.X","dataType":"DInt","isArray":false,"arrayLength":9}""",
out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}