Reading & Parsing Tables

Tables are the fundamental data unit in ANSI C12.19. SharpMeter provides both raw byte access and a built-in deserialization system that converts raw bytes into human-readable named fields.

Standard Tables vs Manufacturing Tables

PSEM tables are split into two ranges:

Range Type ID Format Example
0 – 2047 Standard Tables (ST) ST{id} ST0 = General Config, ST1 = Manufacturer ID
2048 – 4095 Manufacturing Tables (MT) MT{id - 2048} MT0 (ID 2048), MT64 (ID 2112)

Standard tables are defined by ANSI C12.19 and are consistent across meter manufacturers. Manufacturing tables are vendor-specific (e.g., GE/Itron, Landis+Gyr, Elster).

// Read a standard table
var st1 = await client.ReadTableAsync(1);        // ST1: Manufacturer ID
var st52 = await client.ReadTableAsync(52);       // ST52: Clock

// Read a manufacturing table (add 2048 offset)
var mt0 = await client.ReadTableAsync(2048);      // MT0: Device Table
var mt64 = await client.ReadTableAsync(2048 + 64); // MT64: Config Constants

// Check table type
if (st1.IsSuccess)
{
    Console.WriteLine(st1.Value.DisplayName);    // "ST1"
    Console.WriteLine(st1.Value.IsStandard);     // true
    Console.WriteLine(st1.Value.IsManufacturing); // false
}

Raw Byte Access

Every table read returns TableData with raw bytes:

var result = await client.ReadTableAsync(1);
if (result.IsSuccess)
{
    ReadOnlyMemory<byte> raw = result.Value.Data;
    int length = result.Value.Length;

    // Manual field extraction
    var manufacturer = Encoding.ASCII.GetString(raw.Span[..4]).TrimEnd('\0');
    var model = Encoding.ASCII.GetString(raw.Span[4..12]).TrimEnd('\0');
    var fwVersion = raw.Span[14];
}

Table Deserialization

SharpMeter includes a table definition and deserialization system that parses raw bytes into named, typed fields — no manual offset math needed.

Using Built-in Definitions

Standard table definitions are pre-loaded for common ANSI C12.19 tables:

using SharpMeter.Core.Tables;

// Create a registry (pre-loaded with ST0, ST1, ST3, ST5, ST8, ST52)
var registry = new TableRegistry();

// Read and parse in one step
var tableResult = await client.ReadTableAsync(1);
if (tableResult.IsSuccess)
{
    var parsed = registry.Deserialize(1, tableResult.Value.Data);
    if (parsed.IsSuccess)
    {
        // Access fields by name
        Console.WriteLine(parsed.Value.GetValue("MANUFACTURER"));      // "GE"
        Console.WriteLine(parsed.Value.GetValue("ED_MODEL"));          // "I210+   "
        Console.WriteLine(parsed.Value.GetValue("FW_VERSION_NUMBER")); // "5"
        Console.WriteLine(parsed.Value.GetValue("MFG_SERIAL_NUMBER")); // "ABC0000000000001"

        // Iterate all fields
        foreach (var field in parsed.Value.Fields)
        {
            Console.WriteLine($"  {field.Definition.Name,-25} = {field.DisplayValue,-20} [{field.HexValue}]");
        }
    }
}

Output:

  MANUFACTURER              = GE                   [47450000]
  ED_MODEL                  = I210+                [493231302B202020]
  HW_VERSION_NUMBER         = 3                    [03]
  HW_REVISION_NUMBER        = 0                    [00]
  FW_VERSION_NUMBER         = 5                    [05]
  FW_REVISION_NUMBER        = 23                   [17]
  MFG_SERIAL_NUMBER         = ABC0000000000001     [4142433030303030...]

Bit Field Parsing

Tables with packed bit fields (like ST0 and ST3) are automatically decomposed:

var st3Result = await client.ReadTableAsync(3);
var parsed = registry.Deserialize(3, st3Result.Value.Data);

if (parsed.IsSuccess)
{
    var statusField = parsed.Value.GetField("ED_STD_STATUS1");
    if (statusField is not null)
    {
        foreach (var bit in statusField.BitFields)
        {
            if (bit.Value != 0) // Only show set flags
                Console.WriteLine($"  {bit.Name}: {bit.DisplayValue}");
        }
    }
}

Output:

  POWER_FAILURE_FLAG: True
  LOW_BATTERY_FLAG: True

Available Standard Tables

The following tables have built-in field definitions (use registry.Deserialize(tableId, data) directly):

Table Bytes Fields Contents
ST0 94 22 Data format, table/procedure availability bitmaps
ST1 32 7 Manufacturer, model, HW/FW version, serial number
ST3 5 4 Meter mode, standard status flags, MFG status flags
ST5 20 1 Meter identification string
ST8 16 4 Procedure response (result code + data)
ST52 7 7 Clock (year, month, day, hour, minute, second, DST/GMT)

Defining Custom Tables

SharpMeter supports three ways to define table structures. Use whichever fits your workflow:

Strategy Best For
Fluent Builder Quick inline definitions, programmatic construction
Attribute Mapping Strongly-typed table models with compile-time safety
JSON Schema External config files, meter-specific table packs, runtime loading

Strategy 1: Fluent Builder

var mt64 = TableDefinitionBuilder.Manufacturing(64, "Configuration Constants")
    .WithDescription("DSP configuration, scale factors, and meter calibration data")
    .WithSize(104)
    .BitField("DSP_CFG", 1, "DSP configuration flags", bits =>
    {
        bits.Bits("EQUATION", 0, 4, (0, "1-Element 2W"), (1, "1-Element 3W"), (2, "2-Element 3W"));
        bits.Flag("LINE_FREQUENCY", 4);  // 0=50Hz, 1=60Hz
        bits.Flag("PULSE_ENABLE", 5);
        bits.Flag("PULSE_TYPE", 6);      // 0=Energy, 1=Quadergy
    })
    .Hex("KT", 4, "Transformer ratio constant")
    .Hex("V_SQR_HR_SF", 2, "Voltage squared hour scale factor")
    .Hex("V_RMS_SF", 2, "Voltage RMS scale factor")
    .Hex("I_SQR_HR_SF", 3, "Current squared hour scale factor")
    .Hex("VA_SF", 3, "VA scale factor")
    .Hex("VAH_SF", 2, "VAh scale factor")
    .UInt16("CREEP_THRESHOLD", "Creep threshold")
    .Ascii("ANSI_FORM", 5, "ANSI form designation")
    .UInt8("METER_BASE", "Meter base type")
    .UInt16("MAX_CLASS_AMPS", "Maximum class amperage")
    .UInt8("ELEMENT_VOLTS", "Element voltage")
    .UInt8("HARDWARE_VERSION", "Hardware version")
    .UInt8("HARDWARE_REVISION", "Hardware revision")
    .Ascii("MFG_SERIAL_ID", 16, "Manufacturing serial ID")
    .Ascii("ENCRYPTED_SERIAL_ID", 16, "Encrypted serial ID")
    .Build();

Register Custom Definitions

var registry = new TableRegistry(); // Pre-loaded with standard tables

// Register a manufacturing table
registry.Register(mt64);

// Now it deserializes automatically
var result = await client.ReadTableAsync(2048 + 64);
var parsed = registry.Deserialize(2048 + 64, result.Value.Data);

Enum Fields

Map byte values to human-readable names:

var definition = TableDefinitionBuilder.Standard(0, "General Config")
    .WithSize(94)
    .Skip(7) // Skip to NAMEPLATE_TYPE
    .Enum("NAMEPLATE_TYPE", "Meter utility type",
        (0, "Gas"), (1, "Water"), (2, "Electric"), (3, "Dev"))
    .Build();

Strategy 2: Attribute Mapping

Decorate a C# record or class to get strongly-typed deserialization — like EF Core but for binary tables:

using SharpMeter.Core.Tables;
using SharpMeter.Core.Tables.Attributes;

[PsemTable(1, "Manufacturer ID", ExpectedSize = 32)]
public sealed record ManufacturerIdTable
{
    [TableField(0, 4, FieldType.Ascii, Description = "Manufacturer code")]
    public string Manufacturer { get; init; } = "";

    [TableField(4, 8, FieldType.Ascii)]
    public string Model { get; init; } = "";

    [TableField(12, 1, FieldType.UInt8)]
    public byte HwVersion { get; init; }

    [TableField(14, 1, FieldType.UInt8)]
    public byte FwVersion { get; init; }

    [TableField(16, 16, FieldType.Ascii)]
    public string SerialNumber { get; init; } = "";
}

// Deserialize directly into a typed object:
var result = await client.ReadTableAsync(1);
var table = TableMapper.Deserialize<ManufacturerIdTable>(result.Value.Data);
if (table.IsSuccess)
{
    Console.WriteLine(table.Value.Manufacturer);   // "GE"
    Console.WriteLine(table.Value.Model);          // "I210+"
    Console.WriteLine(table.Value.HwVersion);      // 3
    Console.WriteLine(table.Value.SerialNumber);   // "ABC0000000000001"
}

// Or register the attribute-defined type in a registry:
var registry = new TableRegistry();
registry.RegisterFromAttributes<ManufacturerIdTable>();

Strategy 3: JSON Schema

Load table definitions from JSON files — ideal for distributing meter-specific table packs:

{
  "tableId": 2112,
  "name": "Configuration Constants",
  "description": "MT64: DSP and calibration data",
  "expectedSize": 104,
  "fields": [
    { "name": "DSP_CFG", "offset": 0, "size": 1, "type": "bitfield",
      "bitFields": [
        { "name": "EQUATION", "startBit": 0, "bitCount": 4,
          "values": { "0": "1-Element 2W", "1": "1-Element 3W", "2": "2-Element 3W" } },
        { "name": "LINE_FREQUENCY", "startBit": 4, "bitCount": 1 }
      ] },
    { "name": "KT", "offset": 1, "size": 4, "type": "hex", "description": "Transformer ratio" },
    { "name": "METER_BASE", "offset": 22, "size": 1, "type": "enum",
      "enumValues": { "0": "S-base", "1": "A-base", "2": "K-base" } }
  ]
}
// Load from a JSON string
var definition = TableDefinitionLoader.FromJson(jsonString);

// Load from a file (supports single object or array)
var definitions = TableDefinitionLoader.FromFile("tables/itron-i210.json");

// Load directly into a registry
var registry = new TableRegistry();
registry.LoadFromJson(jsonString);
registry.LoadFromJsonFile("tables/custom-tables.json");

Mixing Strategies

All three approaches produce TableDefinition objects — they're fully interchangeable:

var registry = new TableRegistry();  // Pre-loaded with built-in ST0, ST1, ST3, ST5, ST8, ST52

// Add attribute-defined tables
registry.RegisterFromAttributes<ManufacturerIdTable>();

// Add JSON-defined tables
registry.LoadFromJsonFile("tables/vendor-tables.json");

// Add builder-defined tables
registry.Register(TableDefinitionBuilder.Manufacturing(64, "Config")
    .WithSize(104)
    .UInt8("BYTE_FIELD")
    .Build());

// All deserialize the same way
var parsed = registry.Deserialize(tableId, rawBytes);

Table Discovery (ST0)

ST0 contains bitmaps of which tables the meter supports. Parse it to discover available tables:

var st0 = await client.ReadTableAsync(0);
var parsed = registry.Deserialize(0, st0.Value.Data);
if (parsed.IsSuccess)
{
    // STD_TBLS_USED is a bit array — each set bit = an available standard table
    var stdTables = parsed.Value.GetField("STD_TBLS_USED");
    Console.WriteLine($"Available standard tables: {stdTables?.DisplayValue}");
    // Output: "Available standard tables: 0, 1, 2, 3, 5, 7, 8, 11, 13, 14, 52, 53"

    var mfgTables = parsed.Value.GetField("MFG_TBLS_USED");
    Console.WriteLine($"Available manufacturing tables: {mfgTables?.DisplayValue}");
}

Partial Reads

For large tables, read specific sections:

// Read only the serial number from ST1 (offset 16, length 16)
var partial = await client.ReadTableOffsetAsync(1, offset: 16, count: 16);
if (partial.IsSuccess)
{
    var serial = Encoding.ASCII.GetString(partial.Value.Data.Span).TrimEnd('\0');
    Console.WriteLine($"Serial: {serial}");
}

Streaming Large Tables

For tables larger than the packet size, stream in chunks:

await foreach (var chunk in client.StreamTableAsync(
    tableId: 2048 + 64,  // MT64
    chunkSize: 64,
    totalSize: 104))
{
    if (chunk.IsSuccess)
        Console.WriteLine($"Got {chunk.Value.Length} bytes at offset ...");
}
Last updated: 2026-04-08
Was this page helpful?