Class Sealed
public sealed class DlmsClient : IAsyncDisposable

Namespace: SharpMeter.Dlms

High-level DLMS/COSEM client for communicating with IEC 62056 compliant meters. Supports HDLC transport, association management, and GET/SET/ACTION operations.

Inheritance

Inherits from: IAsyncDisposable

Constructors

NameDescription
DlmsClient(…) Initializes a new instance of DlmsClient.

DlmsClient(ITransport transport, DlmsClientOptions options, DlmsClient> logger)

DlmsClient.DlmsClient(ITransport transport, DlmsClientOptions options, ILogger<DlmsClient> logger)

Initializes a new instance of DlmsClient.

Parameters

NameTypeDescription
transportITransportThe transport layer implementation.
optionsSharpMeter.Dlms.DlmsClientOptionsClient configuration options.
loggerILogger<SharpMeter.Dlms.DlmsClient>The logger instance.

Properties

NameDescription
IsAssociated Gets whether an application association is currently active.

IsAssociated

bool DlmsClient.IsAssociated { get; set; }

Gets whether an application association is currently active.

Methods

NameDescription
ActionAsync(…) Invokes a method on a COSEM object.
ConnectAsync(CancellationToken cancellationToken) Establishes HDLC connection and DLMS application association.
DisconnectAsync(CancellationToken cancellationToken) Gracefully releases the DLMS association and HDLC connection.
DisposeAsync()
GetAttributeAsync(…) Reads an attribute value from a COSEM object identified by OBIS code.
GetAttributeListAsync(ObisCode ObisCode, byte AttributeIndex)> descriptors, CancellationToken cancellationToken) Reads multiple attribute values in a single request.
SetAttributeAsync(…) Writes an attribute value to a COSEM object identified by OBIS code.

ActionAsync(ushort classId, ObisCode obisCode, byte methodIndex, ReadOnlyMemory? parameters, CancellationToken cancellationToken)

ValueTask<Result<ReadOnlyMemory<byte>>> DlmsClient.ActionAsync(ushort classId, ObisCode obisCode, byte methodIndex, ReadOnlyMemory<byte>? parameters = null, CancellationToken cancellationToken = null)

Invokes a method on a COSEM object.

Parameters

NameTypeDescription
classIdushortThe COSEM interface class ID.
obisCodeSharpMeter.Dlms.Enums.ObisCodeThe OBIS code identifying the object.
methodIndexbyteThe method index to invoke (1-based).
parametersReadOnlyMemory<byte>?Optional A-XDR encoded method parameters.
cancellationTokenCancellationTokenCancellation token.

Returns: The method return data, or an error.

ConnectAsync(CancellationToken cancellationToken)

ValueTask<Result<bool>> DlmsClient.ConnectAsync(CancellationToken cancellationToken = null)

Establishes HDLC connection and DLMS application association.

Parameters

NameTypeDescription
cancellationTokenCancellationTokenCancellation token.

Returns: A result indicating success or failure.

DisconnectAsync(CancellationToken cancellationToken)

ValueTask DlmsClient.DisconnectAsync(CancellationToken cancellationToken = null)

Gracefully releases the DLMS association and HDLC connection.

Parameters

NameTypeDescription
cancellationTokenCancellationTokenCancellation token.

GetAttributeAsync(ushort classId, ObisCode obisCode, byte attributeIndex, CancellationToken cancellationToken)

ValueTask<Result<ReadOnlyMemory<byte>>> DlmsClient.GetAttributeAsync(ushort classId, ObisCode obisCode, byte attributeIndex, CancellationToken cancellationToken = null)

Reads an attribute value from a COSEM object identified by OBIS code.

Parameters

NameTypeDescription
classIdushortThe COSEM interface class ID.
obisCodeSharpMeter.Dlms.Enums.ObisCodeThe OBIS code identifying the object.
attributeIndexbyteThe attribute index to read (1-based).
cancellationTokenCancellationTokenCancellation token.

Returns: The raw attribute value bytes, or an error.

GetAttributeListAsync(ObisCode ObisCode, byte AttributeIndex)> descriptors, CancellationToken cancellationToken)

IAsyncEnumerable<Result<ReadOnlyMemory<byte>>> DlmsClient.GetAttributeListAsync(IReadOnlyList<(ushort ClassId, ObisCode ObisCode, byte AttributeIndex)> descriptors, CancellationToken cancellationToken = null)

Reads multiple attribute values in a single request.

Parameters

NameTypeDescription
descriptorsIReadOnlyList<(ushort ClassId, SharpMeter.Dlms.Enums.ObisCode ObisCode, byte AttributeIndex)>The list of (classId, obisCode, attributeIndex) tuples.
cancellationTokenCancellationTokenCancellation token.

Returns: An async enumerable of attribute value results.

SetAttributeAsync(ushort classId, ObisCode obisCode, byte attributeIndex, ReadOnlyMemory value, CancellationToken cancellationToken)

ValueTask<Result<bool>> DlmsClient.SetAttributeAsync(ushort classId, ObisCode obisCode, byte attributeIndex, ReadOnlyMemory<byte> value, CancellationToken cancellationToken = null)

Writes an attribute value to a COSEM object identified by OBIS code.

Parameters

NameTypeDescription
classIdushortThe COSEM interface class ID.
obisCodeSharpMeter.Dlms.Enums.ObisCodeThe OBIS code identifying the object.
attributeIndexbyteThe attribute index to write (1-based).
valueReadOnlyMemory<byte>The A-XDR encoded value to write.
cancellationTokenCancellationTokenCancellation token.

Returns: A result indicating success or failure.

Type Relationships
classDiagram
                    style DlmsClient fill:#f9f,stroke:#333,stroke-width:2px
                    DlmsClient --|> IAsyncDisposable : inherits
                
View Source
/// <summary>
///     High-level DLMS/COSEM client for communicating with IEC 62056 compliant meters.
///     Supports HDLC transport, association management, and GET/SET/ACTION operations.
/// </summary>
public sealed partial class DlmsClient : IAsyncDisposable
{
#region Constructor
    /// <summary>
    ///     Initializes a new instance of <see cref = "DlmsClient"/>.
    /// </summary>
    /// <param name = "transport">The transport layer implementation.</param>
    /// <param name = "options">Client configuration options.</param>
    /// <param name = "logger">The logger instance.</param>
    public DlmsClient(ITransport transport, DlmsClientOptions options, ILogger<DlmsClient> logger)
    {
        _transport = transport;
        _options = options;
        _logger = logger;
    }

#endregion
#region Properties
    /// <summary>Gets whether an application association is currently active.</summary>
    public bool IsAssociated { get; private set; }

#endregion
#region IAsyncDisposable
    /// <inheritdoc/>
    public async ValueTask DisposeAsync()
    {
        await DisconnectAsync();
        await _transport.DisposeAsync();
    }

#endregion
#region SET Operations
    /// <summary>
    ///     Writes an attribute value to a COSEM object identified by OBIS code.
    /// </summary>
    /// <param name = "classId">The COSEM interface class ID.</param>
    /// <param name = "obisCode">The OBIS code identifying the object.</param>
    /// <param name = "attributeIndex">The attribute index to write (1-based).</param>
    /// <param name = "value">The A-XDR encoded value to write.</param>
    /// <param name = "cancellationToken">Cancellation token.</param>
    /// <returns>A result indicating success or failure.</returns>
    public async ValueTask<Result<bool>> SetAttributeAsync(ushort classId, ObisCode obisCode, byte attributeIndex, ReadOnlyMemory<byte> value, CancellationToken cancellationToken = default)
    {
        Log.SetAttribute(_logger, obisCode, attributeIndex, classId);
        var request = BuildSetRequest(classId, obisCode, attributeIndex, value.Span);
        Result<byte[]> result = await SendAndReceiveAsync(request, cancellationToken);
        return result.Map(_ => true);
    }

#endregion
#region ACTION Operations
    /// <summary>
    ///     Invokes a method on a COSEM object.
    /// </summary>
    /// <param name = "classId">The COSEM interface class ID.</param>
    /// <param name = "obisCode">The OBIS code identifying the object.</param>
    /// <param name = "methodIndex">The method index to invoke (1-based).</param>
    /// <param name = "parameters">Optional A-XDR encoded method parameters.</param>
    /// <param name = "cancellationToken">Cancellation token.</param>
    /// <returns>The method return data, or an error.</returns>
    public async ValueTask<Result<ReadOnlyMemory<byte>>> ActionAsync(ushort classId, ObisCode obisCode, byte methodIndex, ReadOnlyMemory<byte>? parameters = null, CancellationToken cancellationToken = default)
    {
        Log.ActionMethod(_logger, obisCode, methodIndex, classId);
        ReadOnlySpan<byte> paramSpan = parameters.HasValue ? parameters.Value.Span : ReadOnlySpan<byte>.Empty;
        var request = BuildActionRequest(classId, obisCode, methodIndex, paramSpan);
        Result<byte[]> result = await SendAndReceiveAsync(request, cancellationToken);
        return result.Map(data => (ReadOnlyMemory<byte>)data);
    }

#endregion
#region Log Messages
    private static partial class Log
    {
        [LoggerMessage(Level = LogLevel.Information, Message = "Establishing DLMS connection...")]
        public static partial void EstablishingConnection(ILogger logger);
        [LoggerMessage(Level = LogLevel.Information, Message = "DLMS association established")]
        public static partial void AssociationEstablished(ILogger logger);
        [LoggerMessage(Level = LogLevel.Information, Message = "DLMS connection closed")]
        public static partial void ConnectionClosed(ILogger logger);
        [LoggerMessage(Level = LogLevel.Debug, Message = "GET {ObisCode} attribute {Index} (class {ClassId})")]
        public static partial void GetAttribute(ILogger logger, ObisCode obisCode, byte index, ushort classId);
        [LoggerMessage(Level = LogLevel.Debug, Message = "SET {ObisCode} attribute {Index} (class {ClassId})")]
        public static partial void SetAttribute(ILogger logger, ObisCode obisCode, byte index, ushort classId);
        [LoggerMessage(Level = LogLevel.Debug, Message = "ACTION {ObisCode} method {Index} (class {ClassId})")]
        public static partial void ActionMethod(ILogger logger, ObisCode obisCode, byte index, ushort classId);
        [LoggerMessage(Level = LogLevel.Debug, Message = "SNRM sent, UA received")]
        public static partial void SnrmCompleted(ILogger logger);
        [LoggerMessage(Level = LogLevel.Debug, Message = "AARQ sent, AARE received")]
        public static partial void AarqCompleted(ILogger logger);
    }

#endregion
#region Fields
    private readonly ITransport _transport;
    private readonly ILogger<DlmsClient> _logger;
    private readonly DlmsClientOptions _options;
    private byte _sendSequence;
    private byte _receiveSequence;
#endregion
#region Connection Management
    /// <summary>
    ///     Establishes HDLC connection and DLMS application association.
    /// </summary>
    /// <param name = "cancellationToken">Cancellation token.</param>
    /// <returns>A result indicating success or failure.</returns>
    public async ValueTask<Result<bool>> ConnectAsync(CancellationToken cancellationToken = default)
    {
        Log.EstablishingConnection(_logger);
        // Connect transport
        Result<bool> transportResult = await _transport.ConnectAsync(cancellationToken);
        if (transportResult.IsFailure)
            return transportResult.Error;
        // Send SNRM (HDLC connection)
        Result<bool> snrmResult = await SendHdlcSnrmAsync(cancellationToken);
        if (snrmResult.IsFailure)
            return snrmResult.Error;
        // Send AARQ (Application Association)
        Result<bool> aarqResult = await SendAarqAsync(cancellationToken);
        if (aarqResult.IsFailure)
            return aarqResult.Error;
        IsAssociated = true;
        Log.AssociationEstablished(_logger);
        return true;
    }

    /// <summary>
    ///     Gracefully releases the DLMS association and HDLC connection.
    /// </summary>
    /// <param name = "cancellationToken">Cancellation token.</param>
    public async ValueTask DisconnectAsync(CancellationToken cancellationToken = default)
    {
        if (IsAssociated)
        {
            // Send RLRQ (Release Request)
            await SendRlrqAsync(cancellationToken);
            IsAssociated = false;
        }

        // Send DISC (HDLC disconnect)
        await SendHdlcDiscAsync(cancellationToken);
        await _transport.DisconnectAsync(cancellationToken);
        Log.ConnectionClosed(_logger);
    }

#endregion
#region GET Operations
    /// <summary>
    ///     Reads an attribute value from a COSEM object identified by OBIS code.
    /// </summary>
    /// <param name = "classId">The COSEM interface class ID.</param>
    /// <param name = "obisCode">The OBIS code identifying the object.</param>
    /// <param name = "attributeIndex">The attribute index to read (1-based).</param>
    /// <param name = "cancellationToken">Cancellation token.</param>
    /// <returns>The raw attribute value bytes, or an error.</returns>
    public async ValueTask<Result<ReadOnlyMemory<byte>>> GetAttributeAsync(ushort classId, ObisCode obisCode, byte attributeIndex, CancellationToken cancellationToken = default)
    {
        Log.GetAttribute(_logger, obisCode, attributeIndex, classId);
        var request = BuildGetRequest(classId, obisCode, attributeIndex);
        Result<byte[]> result = await SendAndReceiveAsync(request, cancellationToken);
        return result.Map(data => (ReadOnlyMemory<byte>)data);
    }

    /// <summary>
    ///     Reads multiple attribute values in a single request.
    /// </summary>
    /// <param name = "descriptors">The list of (classId, obisCode, attributeIndex) tuples.</param>
    /// <param name = "cancellationToken">Cancellation token.</param>
    /// <returns>An async enumerable of attribute value results.</returns>
    public async IAsyncEnumerable<Result<ReadOnlyMemory<byte>>> GetAttributeListAsync(IReadOnlyList<(ushort ClassId, ObisCode ObisCode, byte AttributeIndex)> descriptors, [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(descriptors);
        foreach ((var classId, ObisCode obis, var attr)in descriptors)
            yield return await GetAttributeAsync(classId, obis, attr, cancellationToken);
    }

#endregion
#region Private HDLC Methods
    private async ValueTask<Result<bool>> SendHdlcSnrmAsync(CancellationToken cancellationToken)
    {
        var snrm = new HdlcFrame
        {
            Destination = _options.ServerAddress,
            Source = _options.ClientAddress,
            Control = HdlcControl.Snrm,
            Information = ReadOnlyMemory<byte>.Empty
        };
        var frameBytes = snrm.Encode();
        Result<bool> sendResult = await _transport.SendAsync(frameBytes, cancellationToken);
        if (sendResult.IsFailure)
            return sendResult.Error;
        var buffer = new byte[256];
        Result<int> receiveResult = await _transport.ReceiveAsync(buffer, cancellationToken);
        if (receiveResult.IsFailure)
            return receiveResult.Error;
        Log.SnrmCompleted(_logger);
        return true;
    }

    private async ValueTask<Result<bool>> SendAarqAsync(CancellationToken cancellationToken)
    {
        var aarq = BuildAarqApdu();
        Result<byte[]> result = await SendInfoFrameAsync(aarq, cancellationToken);
        if (result.IsFailure)
            return result.Error;
        // Parse AARE response — check association-result
        var aare = result.Value;
        if (aare.Length < 2 || aare[0] != (byte)DlmsApduTag.Aare)
            return new PsemError(PsemErrorCode.ProtocolError, "Invalid AARE response");
        // Search for association-result tag [A2] in the AARE body
        Span<byte> body = aare.AsSpan(2); // skip tag + length
        for (var i = 0; i < body.Length - 2; i++)
            if (body[i] == 0xA2 && i + 2 < body.Length)
            {
                // [A2] len [02] len result-value
                var resultOffset = i + 2;
                if (resultOffset + 2 < body.Length && body[resultOffset] == 0x02)
                {
                    var associationResult = body[resultOffset + 2];
                    if (associationResult != 0) // 0 = accepted
                        return new PsemError(PsemErrorCode.ProtocolError, $"DLMS association rejected: result={associationResult}");
                }

                break;
            }

        Log.AarqCompleted(_logger);
        return true;
    }

    private async ValueTask SendRlrqAsync(CancellationToken cancellationToken)
    {
        byte[] rlrq = [(byte)DlmsApduTag.Rlrq, 0x00];
        await SendInfoFrameAsync(rlrq, cancellationToken);
    }

    private async ValueTask SendHdlcDiscAsync(CancellationToken cancellationToken)
    {
        var disc = new HdlcFrame
        {
            Destination = _options.ServerAddress,
            Source = _options.ClientAddress,
            Control = HdlcControl.Disc,
            Information = ReadOnlyMemory<byte>.Empty
        };
        await _transport.SendAsync(disc.Encode(), cancellationToken);
    }

#endregion
#region Private Framing Methods
    private async ValueTask<Result<byte[]>> SendInfoFrameAsync(byte[] apdu, CancellationToken cancellationToken)
    {
        var frame = new HdlcFrame
        {
            Destination = _options.ServerAddress,
            Source = _options.ClientAddress,
            Control = new HdlcControl(HdlcFrameType.Information, true, _sendSequence, _receiveSequence),
            Information = apdu
        };
        _sendSequence = (byte)((_sendSequence + 1) & 0x07);
        var frameBytes = frame.Encode();
        Result<bool> sendResult = await _transport.SendAsync(frameBytes, cancellationToken);
        if (sendResult.IsFailure)
            return sendResult.Error;
        var buffer = new byte[4096];
        Result<int> receiveResult = await _transport.ReceiveAsync(buffer, cancellationToken);
        if (receiveResult.IsFailure)
            return receiveResult.Error;
        var bytesRead = receiveResult.Value;
        if (bytesRead == 0)
            return PsemError.Transport("No response received");
        Result<HdlcFrame> responseFrame = HdlcFrame.Decode(buffer.AsSpan(0, bytesRead));
        if (responseFrame.IsFailure)
            return responseFrame.Error;
        _receiveSequence = (byte)((_receiveSequence + 1) & 0x07);
        return responseFrame.Value.Information.ToArray();
    }

    private async ValueTask<Result<byte[]>> SendAndReceiveAsync(byte[] apdu, CancellationToken cancellationToken) => await SendInfoFrameAsync(apdu, cancellationToken);
#endregion
#region Private APDU Builders
    private byte[] BuildAarqApdu()
    {
        var apdu = new List<byte>
        {
            (byte)DlmsApduTag.Aarq,
            0x00 // length placeholder
        };
        // Application context name (LN referencing with no ciphering: {2 16 756 5 8 1 1})
        byte[] appContext = [0xA1, 0x09, 0x06, 0x07, 0x60, 0x85, 0x74, 0x05, 0x08, 0x01, 0x01];
        apdu.AddRange(appContext);
        // Authentication mechanism (if configured)
        if (_options.Authentication != AuthenticationMechanism.None)
        {
            // Sender ACSE requirements — authentication functional unit
            byte[] acseRequirements = [0x8A, 0x02, 0x07, 0x80];
            apdu.AddRange(acseRequirements);
            // Mechanism name OID ({2 16 756 5 8 2 <auth-level>})
            byte[] mechName = [0x8B, 0x07, 0x60, 0x85, 0x74, 0x05, 0x08, 0x02, (byte)_options.Authentication];
            apdu.AddRange(mechName);
            // Calling authentication value (password)
            if (_options.Password.Length > 0)
            {
                // [AC] context tag for calling-authentication-value
                apdu.Add(0xAC);
                apdu.Add((byte)(_options.Password.Length + 2));
                // [80] GraphicString choice
                apdu.Add(0x80);
                apdu.Add((byte)_options.Password.Length);
                apdu.AddRange(_options.Password);
            }
        }

        // User-information with xDLMS InitiateRequest
        var initiateRequest = BuildInitiateRequest();
        // [BE] user-information tag
        apdu.Add(0xBE);
        apdu.Add((byte)(initiateRequest.Length + 2));
        // [04] OCTET STRING wrapper
        apdu.Add(0x04);
        apdu.Add((byte)initiateRequest.Length);
        apdu.AddRange(initiateRequest);
        // Fix length
        var result = apdu.ToArray();
        result[1] = (byte)(result.Length - 2);
        return result;
    }

    private byte[] BuildInitiateRequest()
    {
        // xDLMS InitiateRequest APDU
        return[0x01, // initiate-request tag
 0x00, // dedicated-key: absent
 0x00, // response-allowed: true (default)
 0x00, // proposed-quality-of-service: unused
 0x06, // proposed-dlms-version-number: 6
 // proposed-conformance (tag + 4 bytes: [5F 1F] + length + 3 conformance bytes)
        0x5F, 0x1F, 0x04, 0x00, // unused conformance bits
 0x00, 0x1E, 0x1D, // GET, SET, ACTION, selective-access, block-transfer
 // client-max-receive-pdu-size (2 bytes big-endian)
        (byte)(_options.MaxPduSize >> 8), (byte)(_options.MaxPduSize & 0xFF)];
    }

    private static byte[] BuildGetRequest(ushort classId, ObisCode obisCode, byte attributeIndex)
    {
        return[(byte)DlmsApduTag.GetRequest, (byte)GetRequestType.Normal, 0x01, // invoke-id-and-priority
 // Cosem-Attribute-Descriptor (9 bytes)
        (byte)(classId >> 8), (byte)(classId & 0xFF), obisCode.A, obisCode.B, obisCode.C, obisCode.D, obisCode.E, obisCode.F, attributeIndex, 0x00 // access-selection: none
        ];
    }

    private static byte[] BuildSetRequest(ushort classId, ObisCode obisCode, byte attributeIndex, ReadOnlySpan<byte> value)
    {
        // SET-Request-Normal: tag + type + invoke-id + cosem-attribute-descriptor(9) + access-selection + value
        var request = new byte[4 + 9 + value.Length];
        var offset = 0;
        request[offset++] = (byte)DlmsApduTag.SetRequest;
        request[offset++] = (byte)SetRequestType.Normal;
        request[offset++] = 0x01; // invoke-id-and-priority
        // Cosem-Attribute-Descriptor (9 bytes)
        request[offset++] = (byte)(classId >> 8);
        request[offset++] = (byte)(classId & 0xFF);
        request[offset++] = obisCode.A;
        request[offset++] = obisCode.B;
        request[offset++] = obisCode.C;
        request[offset++] = obisCode.D;
        request[offset++] = obisCode.E;
        request[offset++] = obisCode.F;
        request[offset++] = attributeIndex;
        // Access-selection: none
        request[offset++] = 0x00;
        // Value
        value.CopyTo(request.AsSpan(offset));
        return request;
    }

    private static byte[] BuildActionRequest(ushort classId, ObisCode obisCode, byte methodIndex, ReadOnlySpan<byte> parameters)
    {
        var hasParams = parameters.Length > 0;
        // ACTION-Request-Normal: tag + type + invoke-id + cosem-method-descriptor(9) + optional-data
        var request = new byte[3 + 9 + 1 + (hasParams ? parameters.Length : 0)];
        var offset = 0;
        request[offset++] = (byte)DlmsApduTag.ActionRequest;
        request[offset++] = (byte)ActionRequestType.Normal;
        request[offset++] = 0x01; // invoke-id-and-priority
        // Cosem-Method-Descriptor (9 bytes): class-id(2) + instance-id(6) + method-id(1)
        request[offset++] = (byte)(classId >> 8);
        request[offset++] = (byte)(classId & 0xFF);
        request[offset++] = obisCode.A;
        request[offset++] = obisCode.B;
        request[offset++] = obisCode.C;
        request[offset++] = obisCode.D;
        request[offset++] = obisCode.E;
        request[offset++] = obisCode.F;
        request[offset++] = methodIndex;
        if (hasParams)
        {
            request[offset++] = 0x01; // optional-data = present
            parameters.CopyTo(request.AsSpan(offset));
        }
        else
        {
            request[offset] = 0x00; // optional-data = absent
        }

        return request;
    }
#endregion
}
Was this page helpful?