Class Sealed
public sealed class MeterEmulator

Namespace: SharpMeter.Emulator

Full ANSI C12.18 PSEM meter emulator that processes L7 service requests and generates responses.

Constructors

NameDescription
MeterEmulator(EmulatorConfiguration config, MeterEmulator> logger) Initializes a new MeterEmulator with the given configuration.

MeterEmulator(EmulatorConfiguration config, MeterEmulator> logger)

MeterEmulator.MeterEmulator(EmulatorConfiguration config, ILogger<MeterEmulator> logger)

Initializes a new MeterEmulator with the given configuration.

Parameters

NameTypeDescription
configSharpMeter.Emulator.EmulatorConfigurationThe emulator configuration.
loggerILogger<SharpMeter.Emulator.MeterEmulator>The logger instance.

Properties

NameDescription
CurrentAccessLevel Gets the current access level of the logged-on session.
State Gets the current emulator state.
Tables Gets the table store for direct manipulation in tests.

CurrentAccessLevel

AccessLevel MeterEmulator.CurrentAccessLevel { get; set; }

Gets the current access level of the logged-on session.

State

EmulatorState MeterEmulator.State { get; set; }

Gets the current emulator state.

Tables

TableStore MeterEmulator.Tables { get; }

Gets the table store for direct manipulation in tests.

Methods

NameDescription
ProcessRequest(ReadOnlySpan<byte> payload) Processes an L7 PSEM service request and returns the L7 response payload.
Reset() Resets the emulator to its initial state.

ProcessRequest(ReadOnlySpan payload)

byte[] MeterEmulator.ProcessRequest(ReadOnlySpan<byte> payload)

Processes an L7 PSEM service request and returns the L7 response payload.

Parameters

NameTypeDescription
payloadReadOnlySpan<byte>The L7 request payload (service code + parameters).

Returns: The L7 response payload.

Reset()

void MeterEmulator.Reset()

Resets the emulator to its initial state.

View Source
/// <summary>
///     Full ANSI C12.18 PSEM meter emulator that processes L7 service requests and generates responses.
/// </summary>
public sealed partial class MeterEmulator
{
#region Constructor
    /// <summary>
    ///     Initializes a new <see cref = "MeterEmulator"/> with the given configuration.
    /// </summary>
    /// <param name = "config">The emulator configuration.</param>
    /// <param name = "logger">The logger instance.</param>
    public MeterEmulator(EmulatorConfiguration config, ILogger<MeterEmulator> logger)
    {
        _config = config;
        Tables = new TableStore(config);
        _logger = logger;
    }

#endregion
#region Procedure Execution
    private void ExecuteProcedure(byte[] procData)
    {
        if (procData.Length < 2)
            return;
        var procId = (ushort)((procData[0] << 8) | procData[1]);
        var isManufacturer = procId >= PsemConstants.ManufacturingTableOffset;
        var displayId = isManufacturer ? procId - PsemConstants.ManufacturingTableOffset : procId;
        Log.ExecutingProcedure(_logger, isManufacturer ? "MP" : "SP", displayId);
        // Build response in ST8
        var st8 = new byte[16];
        st8[0] = procData[0]; // Procedure ID high
        st8[1] = procData[1]; // Procedure ID low
        st8[2] = 0; // Sequence number
        st8[3] = (byte)ProcedureResult.Completed; // Result: completed
        Tables.WriteTable(PsemConstants.ProcedureResponseTable, st8);
    }

#endregion
#region Log Messages
    private static partial class Log
    {
        [LoggerMessage(Level = LogLevel.Debug, Message = "Processing service: {Service} (state={State})")]
        public static partial void ProcessingService(ILogger logger, ServiceCode service, EmulatorState state);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Emulator state reset")]
        public static partial void StateReset(ILogger logger);
        [LoggerMessage(Level = LogLevel.Debug, Message = "{Service}: {Result}")]
        public static partial void ServiceResult(ILogger logger, string service, string result);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Negotiate: OK (maxPacket={MaxPacket})")]
        public static partial void NegotiateResult(ILogger logger, int maxPacket);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Logon: OK (user={User})")]
        public static partial void LogonResult(ILogger logger, string user);
        [LoggerMessage(Level = LogLevel.Warning, Message = "Security: password mismatch")]
        public static partial void SecurityPasswordMismatch(ILogger logger);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Read ST/MT {TableId}: table not found")]
        public static partial void TableNotFound(ILogger logger, ushort tableId);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Read table {TableId}: {Length} bytes")]
        public static partial void TableRead(ILogger logger, ushort tableId, int length);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Read offset table {TableId}: not found or invalid range")]
        public static partial void TableOffsetNotFound(ILogger logger, ushort tableId);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Read table {TableId} offset={Offset} count={Count}")]
        public static partial void TableReadOffset(ILogger logger, ushort tableId, int offset, ushort count);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Write table {TableId}: {Length} bytes")]
        public static partial void TableWrite(ILogger logger, ushort tableId, int length);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Write offset table {TableId}: failed")]
        public static partial void TableWriteOffsetFailed(ILogger logger, ushort tableId);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Write table {TableId} offset={Offset}: {Length} bytes")]
        public static partial void TableWriteOffset(ILogger logger, ushort tableId, int offset, int length);
        [LoggerMessage(Level = LogLevel.Debug, Message = "Executing {Type} procedure {Id}")]
        public static partial void ExecutingProcedure(ILogger logger, string type, int id);
    }

#endregion
#region Fields
    private readonly EmulatorConfiguration _config;
    private readonly ILogger<MeterEmulator> _logger;
    private string _loggedOnUser = string.Empty;
    private int _negotiatedMaxPacket;
#endregion
#region Properties
    /// <summary>Gets the current emulator state.</summary>
    public EmulatorState State { get; private set; } = EmulatorState.Idle;
    /// <summary>Gets the table store for direct manipulation in tests.</summary>
    public TableStore Tables { get; }
    /// <summary>Gets the current access level of the logged-on session.</summary>
    public AccessLevel CurrentAccessLevel { get; private set; } = AccessLevel.NoAccess;

#endregion
#region Public Methods
    /// <summary>
    ///     Processes an L7 PSEM service request and returns the L7 response payload.
    /// </summary>
    /// <param name = "payload">The L7 request payload (service code + parameters).</param>
    /// <returns>The L7 response payload.</returns>
    public byte[] ProcessRequest(ReadOnlySpan<byte> payload)
    {
        if (payload.Length < 1)
            return[(byte)ResponseCode.Error];
        var serviceCode = (ServiceCode)payload[0];
        ReadOnlySpan<byte> parameters = payload.Length > 1 ? payload[1..] : ReadOnlySpan<byte>.Empty;
        Log.ProcessingService(_logger, serviceCode, State);
        return serviceCode switch
        {
            ServiceCode.Identify => HandleIdentify(),
            ServiceCode.Terminate => HandleTerminate(),
            ServiceCode.Disconnect => HandleDisconnect(),
            ServiceCode.ReadFull => HandleReadFull(parameters),
            ServiceCode.ReadOffset => HandleReadOffset(parameters),
            ServiceCode.WriteFull => HandleWriteFull(parameters),
            ServiceCode.WriteOffset => HandleWriteOffset(parameters),
            ServiceCode.Logon => HandleLogon(parameters),
            ServiceCode.Security => HandleSecurity(parameters),
            ServiceCode.Logoff => HandleLogoff(),
            ServiceCode.Wait => HandleWait(),
            _ when (byte)serviceCode >= 0x60 && (byte)serviceCode <= 0x6F => HandleNegotiate(parameters),
            _ => [(byte)ResponseCode.ServiceNotSupported]
        };
    }

    /// <summary>
    ///     Resets the emulator to its initial state.
    /// </summary>
    public void Reset()
    {
        State = EmulatorState.Idle;
        CurrentAccessLevel = AccessLevel.NoAccess;
        _loggedOnUser = string.Empty;
        Log.StateReset(_logger);
    }

#endregion
#region Service Handlers
    private byte[] HandleIdentify()
    {
        if (State != EmulatorState.Idle)
            return[(byte)ResponseCode.InvalidServiceSequenceState];
        State = EmulatorState.Identified;
        Log.ServiceResult(_logger, "Identify", "OK");
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleNegotiate(ReadOnlySpan<byte> parameters)
    {
        if (State != EmulatorState.Identified)
            return[(byte)ResponseCode.InvalidServiceSequenceState];
        State = EmulatorState.Negotiated;
        var maxPacket = parameters.Length >= 2 ? (parameters[0] << 8) | parameters[1] : 0;
        _negotiatedMaxPacket = maxPacket;
        Log.NegotiateResult(_logger, maxPacket);
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleLogon(ReadOnlySpan<byte> parameters)
    {
        if (State is not (EmulatorState.Negotiated or EmulatorState.Identified))
            return[(byte)ResponseCode.InvalidServiceSequenceState];
        if (parameters.Length >= 12)
        {
            var userName = Encoding.ASCII.GetString(parameters.Slice(2, 10)).TrimEnd('\0');
            _loggedOnUser = userName;
            CurrentAccessLevel = AccessLevel.NoAccess;
        }

        State = EmulatorState.LoggedOn;
        Log.LogonResult(_logger, _loggedOnUser);
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleSecurity(ReadOnlySpan<byte> parameters)
    {
        if (State != EmulatorState.LoggedOn)
            return[(byte)ResponseCode.InvalidServiceSequenceState];
        if (_config.Password.Length > 0)
        {
            var expected = new byte[20];
            _config.Password.AsSpan(0, Math.Min(_config.Password.Length, 20)).CopyTo(expected);
            var provided = new byte[20];
            if (parameters.Length > 0)
                parameters[..Math.Min(parameters.Length, 20)].CopyTo(provided);
            if (!expected.AsSpan().SequenceEqual(provided))
            {
                Log.SecurityPasswordMismatch(_logger);
                return[(byte)ResponseCode.InsufficientSecurityClearance];
            }
        }

        CurrentAccessLevel = AccessLevel.Master;
        State = EmulatorState.Authenticated;
        Log.ServiceResult(_logger, "Security", "OK (access=Master)");
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleLogoff()
    {
        CurrentAccessLevel = AccessLevel.NoAccess;
        _loggedOnUser = string.Empty;
        State = EmulatorState.Identified;
        Log.ServiceResult(_logger, "Logoff", "OK");
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleTerminate()
    {
        State = EmulatorState.Idle;
        CurrentAccessLevel = AccessLevel.NoAccess;
        _loggedOnUser = string.Empty;
        Log.ServiceResult(_logger, "Terminate", "OK");
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleDisconnect()
    {
        State = EmulatorState.Idle;
        CurrentAccessLevel = AccessLevel.NoAccess;
        _loggedOnUser = string.Empty;
        Log.ServiceResult(_logger, "Disconnect", "OK");
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleWait()
    {
        Log.ServiceResult(_logger, "Wait", "OK");
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleReadFull(ReadOnlySpan<byte> parameters)
    {
        if (CurrentAccessLevel < _config.ReadAccessLevel)
            return[(byte)ResponseCode.InsufficientSecurityClearance];
        if (parameters.Length < 2)
            return[(byte)ResponseCode.InappropriateAction];
        var tableId = (ushort)((parameters[0] << 8) | parameters[1]);
        var data = Tables.ReadTable(tableId);
        if (data is null)
        {
            Log.TableNotFound(_logger, tableId);
            return[(byte)ResponseCode.OperationNotPossible];
        }

        // Response: OK + count(2 bytes) + data + checksum(1 byte)
        var response = new byte[1 + 2 + data.Length + 1];
        response[0] = (byte)ResponseCode.Ok;
        response[1] = (byte)(data.Length >> 8);
        response[2] = (byte)(data.Length & 0xFF);
        data.CopyTo(response, 3);
        // Simple checksum (sum of data bytes)
        byte checksum = 0;
        foreach (var b in data)
            checksum += b;
        response[^1] = (byte)(~checksum + 1);
        Log.TableRead(_logger, tableId, data.Length);
        return response;
    }

    private byte[] HandleReadOffset(ReadOnlySpan<byte> parameters)
    {
        if (CurrentAccessLevel < _config.ReadAccessLevel)
            return[(byte)ResponseCode.InsufficientSecurityClearance];
        if (parameters.Length < 7)
            return[(byte)ResponseCode.InappropriateAction];
        var tableId = (ushort)((parameters[0] << 8) | parameters[1]);
        var offset = (parameters[2] << 16) | (parameters[3] << 8) | parameters[4];
        var count = (ushort)((parameters[5] << 8) | parameters[6]);
        var data = Tables.ReadTableOffset(tableId, offset, count);
        if (data is null)
        {
            Log.TableOffsetNotFound(_logger, tableId);
            return[(byte)ResponseCode.OperationNotPossible];
        }

        var response = new byte[1 + 2 + data.Length + 1];
        response[0] = (byte)ResponseCode.Ok;
        response[1] = (byte)(data.Length >> 8);
        response[2] = (byte)(data.Length & 0xFF);
        data.CopyTo(response, 3);
        byte checksum = 0;
        foreach (var b in data)
            checksum += b;
        response[^1] = (byte)(~checksum + 1);
        Log.TableReadOffset(_logger, tableId, offset, count);
        return response;
    }

    private byte[] HandleWriteFull(ReadOnlySpan<byte> parameters)
    {
        if (CurrentAccessLevel < _config.WriteAccessLevel)
            return[(byte)ResponseCode.InsufficientSecurityClearance];
        if (parameters.Length < 2)
            return[(byte)ResponseCode.InappropriateAction];
        var tableId = (ushort)((parameters[0] << 8) | parameters[1]);
        var data = parameters[2..].ToArray();
        Tables.WriteTable(tableId, data);
        Log.TableWrite(_logger, tableId, data.Length);
        // Handle procedure initiation (ST7 write triggers procedure execution)
        if (tableId == PsemConstants.ProcedureInitiationTable)
            ExecuteProcedure(data);
        return[(byte)ResponseCode.Ok];
    }

    private byte[] HandleWriteOffset(ReadOnlySpan<byte> parameters)
    {
        if (CurrentAccessLevel < _config.WriteAccessLevel)
            return[(byte)ResponseCode.InsufficientSecurityClearance];
        if (parameters.Length < 5)
            return[(byte)ResponseCode.InappropriateAction];
        var tableId = (ushort)((parameters[0] << 8) | parameters[1]);
        var offset = (parameters[2] << 16) | (parameters[3] << 8) | parameters[4];
        ReadOnlySpan<byte> data = parameters[5..];
        if (!Tables.WriteTableOffset(tableId, offset, data))
        {
            Log.TableWriteOffsetFailed(_logger, tableId);
            return[(byte)ResponseCode.OperationNotPossible];
        }

        Log.TableWriteOffset(_logger, tableId, offset, data.Length);
        return[(byte)ResponseCode.Ok];
    }
#endregion
}
Was this page helpful?