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 ...");
}