Skip to main content

Specification v0.5

info

This is the rendered normative specification, synced from moddef/spec. The Markdown source is the source of truth.

Status: Draft
Version: 0.5
Primary file extensions: .moddef, .moddef.json, .moddef.yaml
Canonical schema: Protobuf
Primary domain: Modbus register map and semantic device model description

v0.5 — commands (2026-07-21) adds multi-step register procedures (§11.7): device-scoped commands with typed params, strictly linear write/poll/read steps, and typed results — plus dynamic read length (length_ref, §11.7.1) for reads whose size is only known at runtime (Bauer BSM OCMF snapshots, Iskra WM3M4 signed billing messages). Driven by moddef#2.

v0.5 (2026-07-18) adds embedded (same-word) mantissa/exponent composed values (§14.2) — a decade exponent packed into the high bits of the same register word as the mantissa (Iskra T5/T6, Eaton PXM "GENERAL FORMAT") — plus the composed-overlap exemption and sub-mapping validation that interleaved mantissa/exponent register pools need. Driven by moddef#1.

v0.4 (2026-06-04) adds the encoding constructs that v0.3 turned out to be missing once it was checked against 136 real device Modbus specifications (energy meters, solar inverters, BESS, EV chargers, HVAC heat pumps). The new constructs are register-referenced scale factors, unavailable/sentinel values, discovered (SunSpec model-chain) addressing, conditional point presence, register-field structs, flag sets, date/time values, repeating arrays with a stride, a few extra storage widths, and distinct write encodings. See §37 Revision History for the full list and the reasons behind it (research/MODDEF-GAP-ANALYSIS.md).

1. Overview

ModDef is a portable file format for describing Modbus devices, their registers, register encodings, data types, semantic meanings, and generated client APIs.

A ModDef file describes both:

  1. The physical Modbus mapping, where a value is located and how it is encoded.
  2. The semantic meaning, what the value represents, such as active power import, frequency, state of charge, or voltage measured between L1 and neutral.

This separation allows tooling to generate typed clients, validate register maps, expose standard methods, and reuse shared definitions across manufacturers and standards.

2. Goals

ModDef is designed to:

  • Provide a machine-readable replacement for vendor PDF and spreadsheet register maps.
  • Support binary, JSON, and YAML encodings with equivalent semantics.
  • Allow strong validation through a linter and compiler.
  • Support code generation for multiple programming languages.
  • Support reusable standard libraries, including SunSpec mappings and ModDef core measurands.
  • Make semantic data access possible through generated methods such as get_active_power_import() or get_voltage(phase="L1-N").
  • Support low-power and embedded use cases via a compact binary representation.
  • Preserve enough detail to avoid ambiguity around endianness, scaling, units, access permissions, and register address interpretation.

3. Non-goals

ModDef does not:

  • Define the Modbus protocol itself.
  • Define how devices are discovered on the network.
  • Require devices to expose self-description over Modbus.
  • Replace SunSpec, OCPP, IEC, or other domain standards.
  • Guarantee that two vendors use the same physical register addresses.
  • Require end users to know or use Protobuf directly.
  • Describe non-Modbus transports or control planes. ModDef is a Modbus description format. Devices whose only machine interface is OCPP, REST, CAN, DNP3, eBUS/EMS, BACnet objects, or a proprietary serial frame are out of scope and cannot be fully modeled by ModDef. (Validation against 136 real device documents found ~28 such devices; see §37.) A future transport-neutral sibling format may address them; v1 does not.

4. File Types

ModDef has three equivalent representations.

4.1 Binary Representation

Extension:

.moddef

The .moddef file is the binary representation of a ModDef document.

Recommended encoding:

  • Protobuf binary encoding of ModDefDocument.
  • Optional wrapper header may be introduced later for magic bytes, schema version, compression, and signatures.
  • The first stable release should avoid compression by default to simplify embedded implementations.

4.2 JSON Representation

Extension:

.moddef.json

The .moddef.json file is the JSON representation of the same document.

It is intended for:

  • Programmatic tooling.
  • Web services.
  • Git diffs where YAML is not preferred.
  • Schema validation.

4.3 YAML Representation

Extension:

.moddef.yaml

The .moddef.yaml file is the human-editable representation.

It is intended for:

  • Handwritten device profiles.
  • Documentation examples.
  • Review in pull requests.
  • Manufacturer authored files.

YAML comments are allowed but must not carry semantic meaning.

4.4 Equivalence Requirement

The following conversions must be lossless:

.moddef.yaml -> .moddef.json -> .moddef
.moddef -> .moddef.json -> .moddef.yaml
.moddef.json -> .moddef -> .moddef.yaml

Canonical tooling must preserve all semantic information.

5. Terminology

5.1 Document

A complete ModDef file.

5.2 Device Profile

A definition for a specific device, product family, or reusable device class.

5.3 Point

A user-visible data point exposed by a device.

Examples:

  • Total active power import
  • Frequency
  • L1-N voltage
  • Battery state of charge
  • Device status
  • Alarm bit field

5.4 Mapping

The physical Modbus location and encoding for a point.

5.5 Register

A 16-bit Modbus register in either the input register or holding register address space.

5.6 Word

A 16-bit quantity. In Modbus register spaces, one register equals one word.

5.7 Measurand

The semantic meaning of a point.

Examples:

  • active_power
  • energy_active_register
  • voltage
  • frequency
  • state_of_charge

A measurand may be qualified by direction, phase, reference, location, aggregation, and context.

6. Document Structure

A ModDef document contains:

  • Metadata
  • Imports
  • Type definitions
  • Measurand definitions
  • Device profiles
  • Register blocks
  • Points
  • Optional variants
  • Optional standard library references

Conceptual structure:

doc_id: example.energy-meter
name: Example Energy Meter
version: 0.1.0

imports:
- uri: moddef:stdlib:measurands:1.0.0
alias: meas

enums:
- type_id: device_status
values:
- value: 0
name: ok
- value: 1
name: warning
- value: 2
name: fault

devices:
- device_id: example-meter
vendor: Example Corp
model: EM-1000
blocks:
- block_id: electrical-measurements
space: HOLDING_REGISTER
start_offset: 0
length_words: 100
points:
- point_id: frequency
name: Grid Frequency
measurand:
measurand_id: frequency
mapping:
space: HOLDING_REGISTER
offset: 10
length_words: 1
storage_type: U16
value_type:
primitive: DECIMAL
transform:
scale:
numerator: 1
denominator: 100
unit: Hz
access: READ_ONLY

7. Address Spaces

ModDef explicitly supports the four Modbus address spaces.

Address space Modbus concept Typical access


COIL 1-bit read/write values read/write DISCRETE_INPUT 1-bit read-only values read-only INPUT_REGISTER 16-bit read-only registers read-only HOLDING_REGISTER 16-bit read/write registers read/write

7.1 Addressing Rule

ModDef stores Modbus addresses as 0-based offsets within each address space.

This avoids ambiguity between:

  • Protocol-level offsets.
  • Vendor documentation addresses.
  • Human notations such as 40001.

Example:

Vendor notation ModDef space ModDef offset


40001 HOLDING_REGISTER 0 40083 HOLDING_REGISTER 82 30001 INPUT_REGISTER 0

Tools may optionally accept human notation, but the canonical document must use 0-based offsets.

7.2 Function Codes

The ModDef address space implies compatible function codes.

Space Read functions Write functions


COIL 01 05, 15 DISCRETE_INPUT 02 none INPUT_REGISTER 04 none HOLDING_REGISTER 03 06, 16

A point may optionally constrain supported function codes if a device has non-standard behavior.

7.3 Dynamic and Discovered Addressing (v0.4)

Some devices do not place data at fixed offsets. The most important case is SunSpec, where a device exposes a chain of models discovered at runtime: a well-known anchor register holds the marker SunS (0x53756E53), followed by repeating (model_id, length) headers that must be walked to learn where each model (and therefore each point) actually lives. Addresses also shift with device composition (e.g. Fronius moves blocks depending on float-vs-integer model selection and whether a meter is present).

A register block may declare a discovery anchor so that the offsets of its points are resolved relative to a model located at run time, rather than being absolute:

blocks:
- block_id: sunspec-inverter
space: HOLDING_REGISTER
discovery:
kind: SUNSPEC # walk SunS marker + (model_id,length) chain
anchor_candidates: [0, 40000, 50000]
model_id: 103 # resolve this block to SunSpec model 103
points:
- point_id: ac_power
mapping:
model_relative_offset: 14 # offset within the resolved model
storage_type: S16
value_type: { primitive: DECIMAL }

Rules:

  • When discovery.kind is SUNSPEC, point offsets are interpreted as model_relative_offset within the resolved model; the absolute address is computed by the codec after the model chain is walked.
  • model_relative_offset is measured from the model's ID register: offset 0 is the model id, offset 1 the model length, and data begins at offset 2. This matches the canonical SunSpec model tables (e.g. model 103 W at offset 14, as in the example above).
  • anchor_candidates lists the register addresses to probe for the SunS marker (commonly 0, 40000, or 50000).
  • A block with no discovery uses absolute offsets as before (default).

See also §17.1 (conditional point presence), which handles points that exist only in some device compositions.

8. Data Types

ModDef separates logical type from physical storage type.

This is necessary because a value may be logically a decimal quantity but physically stored as an integer with scaling.

8.1 Logical Types

Logical types describe what the value means to users and generated clients.

Supported logical types:

  • BOOL
  • INT32
  • UINT32
  • INT64
  • UINT64
  • FLOAT32
  • FLOAT64
  • DECIMAL
  • STRING
  • BYTES
  • ENUM
  • STRUCT
  • ARRAY
  • DATETIME (v0.4): a calendar date/time or duration; see §8.5.
  • FLAGS (v0.4): a set of independently-true boolean flags; see §13.2.

8.2 Storage Types

Storage types describe how values are physically encoded in Modbus.

Supported storage types:

  • BIT
  • U16
  • S16
  • U32
  • S32
  • U64
  • S64
  • IEEE754_F32
  • IEEE754_F64
  • STRING_ASCII
  • STRING_UTF8
  • BYTES_RAW
  • BCD
  • FIXED_POINT
  • COMPOSED
  • U24, U48 (v0.4): 24-bit and 48-bit unsigned integers.
  • S48 (v0.4): 48-bit signed (two's complement) integer.

These odd widths appear in real maps (e.g. 48-bit energy counters). Signed storage types are two's complement by default; sign_magnitude: true may be set on a mapping where a device uses sign-magnitude instead.

8.3 Required Typing Rule

Each point must define:

  • value_type
  • storage_type

This prevents ambiguity.

Example:

storage_type: U16
value_type:
primitive: DECIMAL
transform:
scale:
numerator: 1
denominator: 10

This means the device stores an unsigned 16-bit integer, but the exposed value is decimal.

8.4 Unavailable / Sentinel Values (v0.4)

Almost every real register map reserves one or more raw values that mean "not implemented", "no data", or "over-range" rather than a real reading. Common conventions:

  • SunSpec: 0x8000 for int16, 0x80000000 for int32, 0xFFFF/ 0xFFFFFFFF for unsigned "not accumulated", and a NaN for floats.
  • ABB: 0x7FFF… (max positive); Carlo Gavazzi: 0x7FFF → display EEEE.

ModDef v0.3 had no way to declare these, so a reader could not distinguish a real value from "unavailable". v0.4 adds an optional, per-point sentinel set:

storage_type: S16
value_type: { primitive: DECIMAL }
na_values:
- raw: 0x8000 # decoded result is "unavailable", not a number

Rules:

  • na_values lists raw register patterns (after width/endianness decode, before scaling) that decode to a typed "unavailable" result.
  • A default sentinel set MAY be supplied by a storage_type profile in a standard library (e.g. the SunSpec stdlib package) and inherited by points.
  • Generated clients expose unavailable readings as a typed absence (e.g. None/nil/Optional.empty), never as the raw sentinel.

8.5 Date and Time Values (v0.4)

A DATETIME logical type carries a calendar instant or duration. Devices encode these several ways; the encoding is declared explicitly:

value_type: { primitive: DATETIME }
datetime:
encoding: EPOCH_S # seconds since 1970-01-01T00:00:00Z
width: U32 # or U64

Supported encoding values:

  • EPOCH_S / EPOCH_MS: unsigned seconds/milliseconds since the Unix epoch, stored in a U32/U64.
  • PACKED_BCD_DATETIME: BCD-packed YYYYMMDDhhmmss across registers.
  • SPLIT_FIELDS: separate year/month/day/hour/minute/second sub-fields, described with a register-field struct (§13.1).

DATETIME values are not measurands; they are typically timestamps on log entries, RTC registers, or energy-counter snapshots.

9. Endianness and Word Order

Modbus registers are 16-bit words. Multi-word values must define how words and bytes are ordered.

9.1 Byte Order

Byte order describes byte order within each 16-bit word.

Values:

  • BIG_ENDIAN
  • LITTLE_ENDIAN

9.2 Word Order

Word order describes ordering of 16-bit words in multi-word values.

Values:

  • WORD_BIG_ENDIAN
  • WORD_LITTLE_ENDIAN

9.3 Defaults and Warnings

For single 16-bit register values, byte order is usually not meaningful beyond Modbus wire format.

For multi-word values, tools must warn if either byte order or word order is omitted.

Recommended linter rule:

MDW001: Multi-word value does not explicitly specify byte_order and word_order.

10. Scaling, Offset, and Units

10.1 Rational Scaling

Scale and offset must be represented as rational values, not floating point values.

scale:
numerator: 1
denominator: 100

This avoids cross-language differences when generating code.

10.2 Transform Pipeline

The value transform pipeline is:

  1. Read raw register or bit data.
  2. Decode using storage type, width, sign, byte order, and word order.
  3. Apply scale.
  4. Apply offset.
  5. Apply clamp, if defined.
  6. Apply enum mapping, if defined.
  7. Return logical value.

Formula:

value = raw * scale + offset

10.3 Units

Units should use stable textual identifiers.

Recommended convention:

  • Use UCUM-compatible units where possible.
  • Allow practical electrical unit strings such as W, kW, Wh, kWh, V, A, Hz, %, degC.
  • Linter should warn on unknown unit strings when a known unit exists.

10.4 Dynamic (Register-Referenced) Scaling (v0.4)

The static rational scale of §10.1 cannot express scaling whose factor is read from another register at run time. This is mandatory for SunSpec, since every SunSpec measurement has a companion scale-factor (SF) register and the real value is raw · 10^SF. Several non-SunSpec meters do the same with a decimal-point register or a CT/VT transformer-factor register (Eaton IQ, Janitza short-format, Carrier).

v0.4 lets a transform reference another point for its scale:

# SunSpec-style: value = raw * 10^(value of the referenced SF point)
transform:
scale_ref:
point_id: ac_power_sf # the SF register, read at the same time
mode: POW10 # value = raw * 10^ref
# Generic register-referenced multiplier: value = raw * (value of ref) / div
transform:
scale_ref:
point_id: ct_ratio
mode: MULTIPLY
denominator: 1

Rules:

  • Exactly one of scale (static rational, §10.1) or scale_ref may be set.
  • mode: POW10value = raw · 10^ref (the SunSpec convention; ref is a signed integer, often int16). mode: MULTIPLYvalue = raw · ref / denominator.
  • The referenced point must resolve within the same device profile and is read in the same transaction window; the linter errors if it does not resolve (MDE407, reserved).
  • offset (§10.1) still applies after the dynamic scale.

This is the single most important v0.4 addition: it unblocks essentially every SunSpec device (Fronius, SolarEdge, SMA, Kostal, Solplanet, and parts of Sungrow).

10.5 Value, Scale, or Unit Selected by Another Register (v0.4)

Some devices change the meaning, scale decade, or unit of a register based on the value of another register: an energy "format" register selecting units/kilo/mega, a tariff selector gating which accumulator a register represents, or an OBIS-coded quantity selector. A point may declare a selector_ref describing this indirection:

selector_ref:
point_id: energy_format # value selects the interpretation below
cases:
0: { scale: { numerator: 1, denominator: 1 } } # units
1: { scale: { numerator: 1000, denominator: 1 } } # kilo
2: { scale: { numerator: 1000000, denominator: 1 } }# mega

The codec evaluates the referenced point, then applies the matching case. Lower frequency than §10.4 but required for a handful of meters.

11. Access Modes and Write Semantics

11.1 Access Modes

Supported access modes:

  • READ_ONLY
  • WRITE_ONLY
  • READ_WRITE
  • COMMAND

11.2 Command Points

Command points represent writes that trigger actions.

Examples:

  • Reset energy counter.
  • Clear alarm.
  • Start charging.
  • Stop inverter.
  • Reboot device.

Command point fields may include:

  • write_behavior
  • confirmation_point
  • allowed_values
  • requires_enable_point

11.3 Write Behaviors

Supported write behaviors:

  • DIRECT
  • MOMENTARY
  • LATCH
  • MASKED
  • COMMAND_TRIGGER

11.4 Write Constraints

A writable point may define:

  • Minimum value
  • Maximum value
  • Step
  • Allowed values
  • Required enable condition
  • Cooldown time
  • Safety note

11.5 Distinct Write Encoding (v0.4)

A few devices encode a value differently on write than on read. For example, Carlo Gavazzi accepts a command with a "magic" high byte that does not appear in the read-back. A point may define a write_encoding block that overrides the storage/transform used for writes:

access: READ_WRITE
storage_type: U16
transform: { scale: { numerator: 1, denominator: 10 } } # read path
write_encoding:
storage_type: U16
transform: { scale: { numerator: 1, denominator: 1 } } # write path
prefix_high_byte: 0x80 # device "magic"

When write_encoding is absent, the read encoding is used for writes (the default and the common case).

11.6 Packed Command Arguments (v0.4)

Some command registers pack several operands into one written word (a sub-command id plus an argument, or HIWORD/LOWWORD operands). These are described as a write-side register-field struct (§13.1) on the command point, so each argument has its own bit range, type, and allowed values.

11.7 Commands (Multi-Step Register Procedures) (v0.5)

A command point (§11.2) describes a single register write. Some device procedures are not a single write: they are an ordered sequence of register operations that together accomplish one logical action — write some inputs, arm a trigger register, wait for the device to finish an internal operation, then read a result whose size may only be known at runtime. A Command describes such a procedure declaratively so a generic client can execute it without device-specific code.

Commands are device-scoped: DeviceProfile.commands is a sibling of blocks and variants, and every reference inside a command (a step's point_id, a param name, a result's from binding) resolves within the owning device profile only — the same scoping as scale_ref.point_id, selector_ref.point_id, and confirmation_point (§28.7, MDE407).

A command has three parts:

  • params — typed caller inputs. Each CommandParam has a field name, storage_type, value_type, and its own Mapping (the registers the value is written to). A required param must be written by at least one step (MDE504).
  • steps — the ordered procedure. Execution is strictly linear: steps run in declaration order, there is no branching, and step order is wire order. Three step kinds:
    • write — either {param: <field>} (encode and write a caller param via its mapping) or {trigger: {point_id, value}} (write a fixed value to a point — the arming write).
    • poll — repeatedly read point_id (at interval_ms) until the until condition holds, failing after timeout_ms. Conditions compare the raw pre-transform integer value: EQ/NE against value, MASK as (raw & mask) == value, RANGE as min <= raw <= max (MDE507 for structurally invalid conditions).
    • read — read point_id (honouring length_ref, below) and bind the decoded value to the name into.
  • results — the typed output record. Each CommandResult.field is sourced from a read-step binding (or a point id, read fresh after the steps if never bound). value_type/enum_ref give the decoded type. A command with no results is suspicious but legal (MDW501).

Executor error semantics: a missing required param, an unresolvable step reference, or a poll timeout abort the command at that step; no rollback is attempted (Modbus has no transactions — profiles should order steps so that partial execution is safe, which both worked examples below do naturally).

11.7.1 Dynamic read length (length_ref)

Mapping.length_ref names a point (in the same profile) whose decoded value is the effective word count for this mapping's read, resolved at read time — the read-length analog of scale_ref (§10.4). When length_words is also set it acts as an upper bound (safety clamp): the effective count is min(decoded(length_ref), length_words). The target must resolve in-profile (MDE407), must decode to an integer primitive (MDE505), and the length_ref graph must be acyclic (MDE506). length_ref is a point-level feature — usable by ordinary reads as well as command read steps.

11.7.2 Worked example — Bauer BSM OCMF snapshot (write-heavy, enum-gated poll)

The Bauer BSM-WS36A signing meter produces an OCMF blob via: write operator metadata strings Meta1 (40280, 70 words), Meta2 (40350, 50 words), Meta3 (40400, 50 words) → write 2 ("Update") to the snapshot-status register (41287 for a start snapshot) → poll that register until 0 ("Valid", other values are errors) → read the 496-word OCMF string (43292 area):

commands:
- command_id: start_transaction
params:
- field: meta1
storage_type: STRING_ASCII
value_type: { primitive: STRING }
mapping: { space: HOLDING_REGISTER, offset: 280, length_words: 70 }
required: true
# meta2 / meta3 analogous (offsets 350 / 400)
steps:
- name: write_meta1
write: { param: meta1 }
# write_meta2 / write_meta3 in this literal order
- name: arm
write: { trigger: { point_id: snapshot_status_start, value: 2 } }
- name: wait_valid
poll:
point_id: snapshot_status_start
until: { op: EQ, value: 0 }
interval_ms: 500
timeout_ms: 15000
- name: fetch
read: { point_id: ocmf_start, into: ocmf }
results:
- field: ocmf
from: ocmf
value_type: { primitive: STRING }

11.7.3 Worked example — Iskra WM3M4 signature (dynamic-length read)

The Iskra WM3M4's billing/signature flow ends by reading an Output Message whose length is only known from a separate Output Message Length register. Where such a register reports a word count, that is the canonical length_ref case (offsets are address − 40000 per that profile's convention):

- point_id: output_message
access: READ_ONLY
storage_type: BYTES_RAW
value_type: { primitive: BYTES }
mapping:
space: HOLDING_REGISTER
offset: 7612
length_words: 512 # safety clamp (upper bound)
length_ref: { point_id: output_message_length }

Unit caveat. length_ref resolves to a word count (§11.7.1). On this particular meter registers 47057/47058 are documented as byte counts ("size of JSON output billing dataset", manual §6.5.13), so the shipped profile does not use length_ref for them: it declares the full fixed window and returns the byte length as a command result for the caller to truncate with. ModDef has no byte→word conversion on length_ref; check the unit in the device manual before wiring one up.

The end_transaction command triggers 'E' into the Command Register (0x4500 in the high byte), polls Signature Status (47052) until 15 ("signature OK"), then reads output_message_length and output_message:

- command_id: end_transaction
steps:
- name: end
write: { trigger: { point_id: command_register, value: 0x4500 } }
- name: wait_signed
poll:
point_id: signature_status
until: { op: EQ, value: 15 }
interval_ms: 1000
timeout_ms: 30000
- name: fetch_length
read: { point_id: output_message_length, into: length }
- name: fetch_message
read: { point_id: output_message, into: message }
results:
- field: message
from: message
value_type: { primitive: BYTES }

command_ref (e.g. ocmf:start_transaction) is an optional free-form role tag naming the standard procedure a command implements. It lets a consumer dispatch on role ("which command starts a transaction?") without hard-coding command_id per device. It is deliberately not resolved against any package and not validated: the register-level shape of a procedure varies enough between devices that a checkable signature binding was found not to be expressible (see §37).

12. Enums

Enums are independent reusable type definitions.

An enum is not defined inside a register or point. A point references an enum type.

Example:

enums:
- type_id: operating_state
name: Operating State
values:
- value: 0
name: off
- value: 1
name: standby
- value: 2
name: running
- value: 3
name: fault

Point reference:

point_id: operating_state
storage_type: U16
value_type:
enum_ref:
type_id: operating_state

Validation rules:

  • Enum names must be unique within an enum.
  • Enum numeric values must be unique unless aliases are explicitly allowed.
  • Referenced enum types must exist after import resolution.
  • Linter must report unused enum definitions as optional warnings.

13. Bit Fields

A point may represent:

  • A single bit.
  • A range of bits.
  • A named set of bit fields inside a register.

Example:

point_id: alarm_flags
name: Alarm Flags
mapping:
space: HOLDING_REGISTER
offset: 20
length_words: 1
bit_fields:
- field_id: over_voltage
bit_offset: 0
bit_length: 1
- field_id: under_voltage
bit_offset: 1
bit_length: 1

Multi-bit fields may reference an enum.

13.1 Register Field Extraction / Sub-fields (v0.4)

A bare bit_field (above) extracts a bit range as a boolean or small enum. Real maps frequently pack several independent, fully-typed sub-values into one register or register pair: HIWORD/LOWWORD halves, a byte-tuple firmware version (major.minor.revision), a packed IPv4 address, or a packed-BCD blob holding several sub-values (Eastron demand interval / scroll time / backlight).

v0.4 generalizes bit_fields so each sub-field carries its own type, scale, and unit, i.e. a struct laid over a register window:

point_id: firmware_version
mapping: { space: HOLDING_REGISTER, offset: 8, length_words: 2 }
fields:
- field_id: major
bit_offset: 24
bit_length: 8
value_type: { primitive: UINT32 }
- field_id: minor
bit_offset: 16
bit_length: 8
value_type: { primitive: UINT32 }
- field_id: revision
bit_offset: 0
bit_length: 16
value_type: { primitive: UINT32 }

Each field may carry its own value_type, transform (including scale), unit, enum_ref, and storage_type (e.g. BCD for a packed-BCD sub-value). bit_offset is counted across the whole mapped window (LSB = 0). The legacy bit_fields form remains valid and is the boolean/enum special case.

13.2 Flag Sets (FLAGS) (v0.4)

Status/alarm registers often expose many independent boolean flags that may be true simultaneously (a bitmask), not a single enumerated state. Modeling each as a separate bit_field is verbose; the FLAGS value type names them as a set:

point_id: alarm_flags
storage_type: U32
value_type:
flags:
bits:
0: over_voltage
1: under_voltage
2: over_temperature
7: door_open

A decoded FLAGS value is the set of names whose bits are set. Unlisted bits are preserved as raw for round-tripping.

14. Composed Values

Some devices represent a numeric value as a mantissa/exponent pair — either as separate registers, or (v0.5) packed into the same register word (§14.2).

This represents scientific notation:

value = mantissa * base^exponent

The point's mapping declares the read window covering both parts; sub-mapping offsets are word offsets relative to that window. Each sub-mapping may set a storage_type, which determines the sub-value's signedness (and width); without one the sub-value decodes as signed over its whole window.

Example (mantissa at register 100, exponent at register 101):

point_id: active_power
storage_type: COMPOSED
value_type: { primitive: DECIMAL }
mapping:
space: HOLDING_REGISTER
offset: 100
length_words: 2
composed:
kind: MANTISSA_EXPONENT
base: 10
mantissa:
offset: 0 # window-relative: register 100
storage_type: S16
exponent:
offset: 1 # window-relative: register 101
storage_type: S16

Validation rules:

  • Both mantissa and exponent mappings must be valid.
  • Base must be non-zero (MDE406).
  • The parent mapping must declare length_words covering both sub-mappings; a sub-mapping must lie inside the parent window (MDE408).
  • Mantissa and exponent must not overlap: they either address disjoint words of the window, or disjoint bit windows of the same words (§14.2, MDE408).
  • Composed points are exempt from the cross-point overlap rule MDE301 with respect to each other: parent windows of composed points may overlap freely, since they are read windows over shared mantissa/exponent register pools (e.g. interleaved energy-total layouts). Overlap with a non-composed point remains an error.
  • Generated clients must expose the computed value, not the raw pair, unless raw access is requested.

14.1 Repeating Blocks and Arrays (v0.4)

Many devices repeat a group of registers a fixed or runtime-determined number of times with a constant stride: multi-string PV inputs, per-phase groups, per-cell/per-module battery data, and SunSpec repeating blocks. v0.4 adds stride_words and an optional count_ref to the array/struct mapping:

point_id: cell_voltages
value_type:
array:
element_type: { primitive: DECIMAL }
# length: 16 # fixed count, OR:
count_ref: { point_id: cell_count } # count read from a register
mapping:
space: INPUT_REGISTER
offset: 200
stride_words: 1 # each element starts 1 word after the previous
element_transform:
scale: { numerator: 1, denominator: 1000 }

For arrays of structs, element_type references a struct whose fields use window-relative offsets (§13.1) and stride_words is the size of one element. count_ref resolves a register holding the live element count; when absent, length is fixed.

14.2 Embedded (Same-Word) Mantissa/Exponent (v0.5)

Some meters embed the decade exponent inside the same register word as the mantissa. Iskra's T5/T6 measurement types put a signed 8-bit exponent in bits 31–24 and a 24-bit mantissa (unsigned for T5, signed for T6) in bits 23–0 of one 32-bit word; the Eaton PXM 4000/6000/8000 "GENERAL FORMAT" packs an 8-bit exponent and the top mantissa byte into its first register with a 56-bit mantissa continuing across the remaining three.

v0.5 adds bit_offset + bit_length to composed sub-mappings so mantissa and exponent can window disjoint bit ranges of the same word(s):

  • bit_offset counts from the LSB of the whole assembled sub-mapping window (after byte_order/word_order), exactly as §13.1 RegisterField.
  • A sub-value with a bit window sign-extends from bit_length (not the storage width) when its storage_type is signed.
  • bit_length: 0 (or absent) means "no bit window" — the sub-value is the whole window, as before.

Example — Iskra T6 (signed). The vendor manual's reference value FD 01 E2 40 decodes as exponent = 0xFD = −3, mantissa = 0x01E240 = 123456, so 123456 × 10⁻³ = 123.456:

point_id: active_power_total
storage_type: COMPOSED
value_type: { primitive: DECIMAL }
mapping:
space: INPUT_REGISTER
offset: 140
length_words: 2
composed:
kind: MANTISSA_EXPONENT
base: 10
mantissa:
offset: 0
length_words: 2
byte_order: BIG_ENDIAN
word_order: WORD_BIG_ENDIAN
storage_type: S32 # T6 mantissa is signed (T5: U32)
bit_offset: 0
bit_length: 24
exponent:
offset: 0
length_words: 2
byte_order: BIG_ENDIAN
word_order: WORD_BIG_ENDIAN
storage_type: S16 # exponent is always signed
bit_offset: 24
bit_length: 8

Validation rules (in addition to §14):

  • A bit window must fit its sub-mapping window: bit_offset + bit_length <= length_words * 16 (MDE303).
  • When mantissa and exponent share words, both must carry bit windows and the bit ranges must be disjoint (MDE408).

15. Strings and Raw Bytes

String fields must define:

  • Encoding
  • Register length
  • Padding behavior
  • Termination behavior

Example:

storage_type: STRING_ASCII
length_words: 8
string_encoding:
charset: ASCII
padding: NULL
termination: NULL_TERMINATED

Recommended padding modes:

  • NULL
  • SPACE
  • NONE

Recommended termination modes:

  • FIXED_LENGTH
  • NULL_TERMINATED

16. Device Profiles

A device profile describes one device model, family, or reusable abstract device.

Fields:

  • device_id
  • vendor
  • model
  • family
  • description
  • firmware_versions
  • supported_transports
  • default_unit_id
  • blocks
  • variants

Example:

device_id: example-inverter
vendor: Example Corp
model: INV-5000
supported_transports:
- MODBUS_RTU
- MODBUS_TCP

17. Register Blocks

Register blocks group related points.

A block may correspond to:

  • A contiguous Modbus address range.
  • A functional group.
  • A standard model block, such as a SunSpec model.
  • A vendor-specific extension.

Fields:

  • block_id
  • name
  • space
  • start_offset
  • length_words
  • points

Validation rules:

  • Points in a block should fall within the block range.
  • Overlaps are errors unless explicitly allowed.
  • Blocks may overlap only if declared as overlays.

17.1 Conditional Point Presence (v0.4)

A point may exist only in some firmware versions, options, or device compositions (an optional meter, a battery module, a model present only when a feature register is set). Rather than forcing a separate variant for every combination, a point or block may declare an available_if condition:

- point_id: battery_soc
available_if:
point_id: battery_present # gating register/point
equals: 1
mapping: { space: HOLDING_REGISTER, offset: 520, length_words: 1 }
storage_type: U16
value_type: { primitive: DECIMAL }

A reader skips an unavailable point instead of reading garbage. available_if complements §7.3 discovery (which handles whole models appearing/moving) and §18 variants (which handle named SKUs).

18. Variants

Variants represent firmware, hardware, regional, or option-specific differences.

Example use cases:

  • Same model with different firmware.
  • Optional energy meter module.
  • Single phase and three phase versions.
  • Solar inverter with optional battery module.

A variant may:

  • Add points.
  • Remove points.
  • Override points.
  • Override metadata.

Example:

variants:
- variant_id: firmware-2
name: Firmware 2.x
base_device_id: example-meter
removals:
- deprecated_alarm_register
additions:
- point_id: state_of_charge
...

19. Imports and Packages

Imports allow reuse of external definitions.

19.1 Import URI Types

Supported import URI styles:

./relative-file.moddef.yaml
../common/enums.moddef.yaml
moddef:stdlib:core:1.0.0
moddef:stdlib:measurands:1.0.0
moddef:stdlib:sunspec:1.0.0

19.2 Import Aliases

An import may define an alias.

imports:
- uri: moddef:stdlib:measurands:1.0.0
alias: meas

Qualified references may use the alias:

measurand_id: meas:active_power

19.3 Conflict Rules

  • Local definitions override nothing implicitly.
  • Conflicting imported identifiers are errors unless namespaced by alias.
  • Re-exporting imports must be explicit.

19.4 Package Manifest

A ModDef package should include:

package_id: moddef:stdlib:measurands
version: 1.0.0
files:
- measurands.moddef.yaml
dependencies: []
checksum_algorithm: sha256

20. Standard Libraries

Recommended official packages:

20.1 Core Library

moddef:stdlib:core:<version>

Contains:

  • Common primitive enums.
  • Common status enums.
  • Standard write behavior definitions.
  • Common transform helpers.

20.2 Measurands Library

moddef:stdlib:measurands:<version>

Contains:

  • Core ModDef measurand definitions.
  • Electrical quantity taxonomy.
  • Phase and reference definitions.
  • Default unit compatibility rules.

20.3 SunSpec Library

moddef:stdlib:sunspec:<version>

Contains:

  • SunSpec model mappings represented in ModDef.
  • Versioned packages matching source model revisions where possible.
  • Measurand annotations for common SunSpec fields.

20.4 Vendor Libraries

Vendors may publish packages such as:

vendor:acme:meters:1.2.0
vendor:acme:inverters:2026.04

21. Core Measurands System

The Core Measurands System defines semantic identifiers that can be referenced by points.

The goal is to allow generated clients to expose high-level APIs independent of vendor register addresses.

Example:

device.get_active_power(direction="import")
device.get_voltage(phase="L1-N")
device.get_frequency()

If a device does not support a measurand, the client must return or raise a well-defined MeasurandNotSupported error.

22. Measurand Model

A measurand is composed of:

  • Base quantity
  • Optional direction
  • Optional phase/reference
  • Optional aggregation
  • Optional location
  • Optional context
  • Optional accumulation kind
  • Canonical unit
  • Expected value type

22.1 Base Quantity

Examples:

  • active_power
  • reactive_power
  • apparent_power
  • energy_active
  • energy_reactive
  • energy_apparent
  • voltage
  • current
  • frequency
  • power_factor
  • temperature
  • state_of_charge
  • state_of_health
  • device_status
  • alarm_status

22.2 Direction

Direction is used for quantities where flow direction matters.

Recommended values:

  • NONE
  • IMPORT
  • EXPORT
  • NET
  • CHARGE
  • DISCHARGE
  • FORWARD
  • REVERSE

Examples:

base_quantity: active_power
direction: IMPORT
base_quantity: energy_active
direction: EXPORT
accumulation: REGISTER

22.3 Phase and Reference

Phase and reference are modeled using a phase reference enum inspired by OCPP phase values.

Do not encode every phase combination as a separate measurand name. Instead, define the base measurand and qualify it with phase_ref.

Recommended PhaseRef values:

Value Meaning


NONE Not phase-specific L1 Phase L1 L2 Phase L2 L3 Phase L3 N Neutral L1_N L1 measured against neutral L2_N L2 measured against neutral L3_N L3 measured against neutral L1_L2 Line-to-line measurement from L1 to L2 L2_L3 Line-to-line measurement from L2 to L3 L3_L1 Line-to-line measurement from L3 to L1

Textual wire values may be rendered in OCPP-compatible form:

ModDef enum Text value


L1_N L1-N L2_N L2-N L3_N L3-N L1_L2 L1-L2 L2_L3 L2-L3 L3_L1 L3-L1

Examples:

Voltage between L1 and neutral:

measurand:
base_quantity: voltage
phase_ref: L1_N

Voltage between L1 and L2:

measurand:
base_quantity: voltage
phase_ref: L1_L2

Current on L1:

measurand:
base_quantity: current
direction: IMPORT
phase_ref: L1

Total active power import:

measurand:
base_quantity: active_power
direction: IMPORT
aggregation: TOTAL

22.4 Aggregation

Aggregation describes whether a measurement is per phase, total, average, minimum, maximum, or otherwise aggregated.

Recommended values:

  • NONE
  • TOTAL
  • AVERAGE
  • MINIMUM
  • MAXIMUM
  • INSTANTANEOUS

Examples:

base_quantity: active_power
direction: IMPORT
aggregation: TOTAL
base_quantity: voltage
phase_ref: L1_N
aggregation: INSTANTANEOUS

22.5 Location

Location describes where the measurement is taken.

Recommended values:

  • UNSPECIFIED
  • GRID
  • LOAD
  • INLET
  • OUTLET
  • BATTERY
  • PV
  • INVERTER
  • EV
  • CABLE
  • BODY
  • INTERNAL
  • EXTERNAL

This is useful for devices with multiple measurement locations.

Examples:

base_quantity: temperature
location: INTERNAL
base_quantity: energy_active
direction: IMPORT
location: GRID
accumulation: REGISTER

22.6 Accumulation

Accumulation describes whether a value is instantaneous, a cumulative register, or an interval value.

Recommended values:

  • INSTANTANEOUS
  • REGISTER
  • INTERVAL
  • LIFETIME
  • SESSION
  • DAILY
  • MONTHLY

Example:

base_quantity: energy_active
direction: IMPORT
accumulation: REGISTER

22.7 Context

Context describes why or when a value is sampled.

Recommended values:

  • UNSPECIFIED
  • SAMPLE_PERIODIC
  • SAMPLE_CLOCK
  • TRANSACTION_BEGIN
  • TRANSACTION_END
  • INTERRUPTION_BEGIN
  • INTERRUPTION_END
  • TRIGGERED
  • MANUAL

Context is optional and is primarily useful for telemetry streams, not static register maps.

23. Core Measurand Catalog

The core catalog should be extensible. It should not try to include every possible vendor-specific concept in v1.0.

23.1 Electrical Power


Base quantity Direction Default unit Notes


active_power import, export, W Instantaneous net real power

reactive_power import, export, var Instantaneous net reactive power

apparent_power none, import, VA Instantaneous export apparent power

power_factor none 1 Ratio, dimensionless

Phase-qualified examples:

  • active_power, direction IMPORT, phase_ref L1
  • active_power, direction IMPORT, phase_ref L2
  • active_power, direction IMPORT, phase_ref L3
  • active_power, direction IMPORT, aggregation TOTAL

23.2 Electrical Energy

Base quantity Direction Accumulation Default unit


energy_active import, export, net register Wh energy_reactive import, export, net register varh energy_apparent import, export register VAh

OCPP-style names can be mapped as aliases:


OCPP-style name ModDef semantic tuple


Energy.Active.Import.Register base energy_active, direction IMPORT, accumulation REGISTER

Energy.Active.Export.Register base energy_active, direction EXPORT, accumulation REGISTER

Energy.Reactive.Import.Register base energy_reactive, direction IMPORT, accumulation REGISTER

Energy.Reactive.Export.Register base energy_reactive, direction EXPORT, accumulation REGISTER

23.3 Voltage

Base quantity Phase reference Default unit


voltage L1_N V voltage L2_N V voltage L3_N V voltage L1_L2 V voltage L2_L3 V voltage L3_L1 V voltage none V

Important rule:

  • Phase-to-neutral and line-to-line voltages are different semantic measurements.
  • A point with phase_ref: L1 should not be treated as equivalent to phase_ref: L1_N unless the device profile explicitly declares that equivalence.

23.4 Current

Base quantity Direction Phase reference Default unit


current import L1 A current import L2 A current import L3 A current export L1 A current export L2 A current export L3 A current none N A

23.5 Frequency

Base quantity Default unit


frequency Hz

Frequency is normally not phase-specific.

23.6 Battery and Storage

Base quantity Default unit Notes


state_of_charge % Battery charge level state_of_health % Battery health battery_voltage V Battery terminal voltage battery_current A Battery current battery_power W Battery power charge_limit W or A Unit must disambiguate discharge_limit W or A Unit must disambiguate

23.7 Solar and Inverter

Base quantity Default unit


pv_voltage V pv_current A pv_power W dc_bus_voltage V inverter_temperature degC inverter_status enum

23.8 Thermal

Base quantity Location Default unit


temperature internal degC temperature external degC temperature battery degC temperature inverter degC

23.9 Status and Alarms

Base quantity Default type


device_status enum alarm_status enum or bit field fault_code enum or integer warning_code enum or integer communication_status enum

24. Measurand Aliases

ModDef should support aliases to map external naming systems to the ModDef semantic tuple model.

Example:

measurand_aliases:
- alias: Energy.Active.Import.Register
source: OCPP_1_6
maps_to:
base_quantity: energy_active
direction: IMPORT
accumulation: REGISTER

Example:

measurand_aliases:
- alias: Voltage.L1-N
source: OCPP_1_6
maps_to:
base_quantity: voltage
phase_ref: L1_N

This allows ModDef to be compatible with OCPP terminology without making OCPP the core namespace.

25. Measurand Validation

Linter and compiler validation rules:

  • A point may have zero or one measurand annotation.
  • A device profile must not expose duplicate semantic measurands unless explicitly allowed.
  • If duplicate measurands are allowed, they must differ by location, phase reference, context, or role.
  • The point unit must be compatible with the measurand canonical unit.
  • The point value type must be compatible with the measurand expected type.
  • Phase references must be valid for the base quantity.
  • Line-to-line voltage values must use L1_L2, L2_L3, or L3_L1.
  • Line-to-neutral voltage values must use L1_N, L2_N, or L3_N.
  • Current and per-phase power normally use L1, L2, or L3, not L1_N.
  • Frequency should not be phase-specific unless explicitly allowed by a profile extension.

Example linter messages:

MDE101: Unknown measurand base_quantity 'active_powr'.
MDE102: Point 'voltage_l1' uses unit 'A', expected unit compatible with 'V'.
MDE103: Duplicate measurand voltage/L1_N in device profile 'meter-a'.
MDW104: Point 'frequency_l1' uses a phase reference for frequency.

26. Generated Measurand APIs

Generated client libraries should expose both generic and convenience accessors.

26.1 Generic API

Python example:

device.get_measurand(
base_quantity="voltage",
phase_ref="L1-N"
)

Go example:

device.GetMeasurand(ctx, moddef.MeasurandQuery{
BaseQuantity: moddef.Voltage,
PhaseRef: moddef.PhaseL1N,
})

26.2 Convenience API

Python:

device.get_frequency()
device.get_voltage(phase="L1-N")
device.get_active_power(direction="import", aggregation="total")
device.get_energy_active(direction="import", accumulation="register")

Go:

device.Frequency(ctx)
device.Voltage(ctx, moddef.PhaseL1N)
device.ActivePower(ctx, moddef.Import, moddef.Total)
device.EnergyActive(ctx, moddef.Import, moddef.Register)

26.3 Unsupported Measurands

If a measurand is not supported, clients must raise or return a standard error.

Python:

raise MeasurandNotSupported("voltage", phase_ref="L1-N")

Go:

return 0, moddef.ErrMeasurandNotSupported

26.4 Ambiguous Measurands

If multiple points match a query, the client must not choose silently.

It must either:

  • Return an ambiguity error.
  • Require additional query qualifiers.
  • Use a profile-defined default if explicitly declared.

27. Protobuf Schema Draft

This schema is a starting point for development. Field numbers are draft and may change before v1.0. It reflects v0.4 (it now also includes the §11 write semantics, §15 string encoding, §14 composed kind, §7.2 function-code constraints, §16 firmware_versions, and §17 overlay flag that the v0.3 draft described in prose but omitted from the schema, plus all v0.4 additions).

The canonical schema is the Protobuf definition in moddef/proto/moddef/v1:

The full schema is omitted here to keep the rendered spec readable; the .proto files above are the source of truth.

28. Validation Rules

A compliant ModDef document must satisfy the following.

28.1 Structural Validation

  • Document id must be present.
  • Document version must be present.
  • Device profile ids must be unique.
  • Point ids must be unique within a device profile.
  • Enum ids must be unique after imports are resolved.
  • Struct ids must be unique after imports are resolved.
  • Measurand ids must be unique after imports are resolved.

28.2 Import Validation

  • All imports must resolve.
  • Import checksums must match if specified.
  • Import cycles must be detected.
  • Name collisions must be rejected unless aliased.

28.3 Mapping Validation

  • Register ranges must not overlap unless explicitly declared as overlays, or both points are composed (§14 — composed parent windows are read windows over shared mantissa/exponent pools).
  • Points inside a register block must fall within the block range.
  • Multi-word values must define byte order and word order, or emit a warning.
  • Bit index must be valid for the mapped width.
  • Coil and discrete input mappings must not define length_words.

28.4 Type Validation

  • Storage type and value type must be compatible.
  • Enum references must resolve.
  • Struct references must resolve.
  • String storage must define length and encoding.
  • Scale denominator must not be zero.

28.5 Access Validation

  • Read-only points must not be generated with write methods.
  • Write-only points must not be generated with read methods unless explicitly allowed as unsafe raw access.
  • Command points must define write semantics.

28.6 Measurand Validation

  • Measurand references must resolve if using measurand_id.
  • Inline semantic tuples must use known base quantities unless vendor extension mode is enabled.
  • Phase references must be compatible with base quantity.
  • Units must be compatible with the canonical unit.
  • Duplicate semantic measurands must be rejected unless qualified or explicitly allowed.

28.7 v0.4 Construct Validation

  • transform must set at most one of scale and scale_ref.
  • A scale_ref / selector_ref / available_if / count_ref / length_ref target must resolve to a point in the same device profile (MDE407).
  • A point whose value_type.primitive is DATETIME must define datetime.
  • A point whose value_type.kind is flags must define at least one bit.
  • na_values raw patterns must fit the decoded storage width.
  • A RegisterField (fields) range must lie within the mapped window and fields must not overlap unless explicitly allowed.
  • A block with discovery.kind = SUNSPEC must use model_relative_offset on its points (not absolute offset).
  • stride_words must be ≥ the element width for repeating arrays.

28.8 v0.5 Construct Validation

  • A composed point's parent mapping must declare length_words; each sub-mapping must lie inside the parent window — sub-mapping offsets are window-relative (MDE408).
  • A composed sub-mapping's bit window must fit its window: bit_offset + bit_length <= length_words * 16 (MDE303).
  • Composed mantissa and exponent must not overlap: disjoint words, or disjoint bit windows of the same words (MDE408).

28.9 v0.5 Commands Construct Validation

  • command_id must be unique within a device profile (MDE502).
  • Every step/result reference — WriteStep.param, PollStep.point_id, ReadStep.point_id, TriggerWrite.point_id, CommandResult.from — must resolve to a param field, a step binding (ReadStep.into), or a point in the same device profile, as appropriate for the reference (MDE503).
  • A required CommandParam must be written by at least one WriteStep (MDE504).
  • A length_ref target must decode to an integer primitive (MDE505).
  • The length_ref graph must be acyclic, including self-reference (MDE506).
  • A PollStep.until condition must be structurally valid for its op: op set, RANGE with min <= max, MASK with a non-zero mask (MDE507).
  • A WriteStep must not target a READ_ONLY point (MDE508).
  • A command should declare at least one result (MDW501, warning).

29. Tooling Specification

29.1 CLI Tool

Command:

moddef

Required commands:

moddef build
moddef lint
moddef fmt
moddef convert
moddef pack
moddef gen
moddef inspect

29.1.1 build

Builds .moddef.yaml or .moddef.json into .moddef.

moddef build device.moddef.yaml

29.1.2 lint

Validates the document and reports errors and warnings.

moddef lint device.moddef.yaml

29.1.3 fmt

Rewrites YAML or JSON into canonical formatting.

moddef fmt device.moddef.yaml

29.1.4 convert

Converts between supported encodings.

moddef convert device.moddef.yaml device.moddef.json
moddef convert device.moddef.json device.moddef

29.1.5 pack

Builds a reusable package.

moddef pack ./stdlib/measurands

29.1.6 gen

Generates client code.

moddef gen --lang python --input device.moddef.yaml --out ./generated

29.1.7 inspect

Prints a human-readable summary.

moddef inspect device.moddef

29.2 Exit Codes

Code Meaning


0 success 1 validation error 2 parse error 3 import resolution error 4 generation error 5 internal error

30. Linter Rules

Recommended linter rule categories:

30.1 Errors

  • Overlapping mappings.
  • Unknown enum reference.
  • Unknown measurand reference.
  • Invalid scale denominator.
  • Invalid phase reference.
  • Unit incompatible with measurand.
  • Unsupported storage and logical type combination.

30.2 Warnings

  • Missing description.
  • Missing endianness for multi-word point.
  • Unknown unit string.
  • Unused enum.
  • Unused import.
  • Duplicate human-readable names.
  • Suspicious 1-based address converted to 0-based.

30.3 Linter Configuration

File:

.moddeflintrc.yaml

Example:

rules:
MDW001:
severity: error
MDW_DESCRIPTION_MISSING:
severity: warning

31. Code Generation

Generated code should provide:

  • Typed point access.
  • Generic point access by id.
  • Generic measurand access.
  • Convenience measurand methods.
  • Enum types.
  • Decode and encode functions.
  • Optional Modbus transport wrappers.

Supported target languages:

  • Python
  • Go
  • TypeScript
  • C
  • C++
  • Rust

32. Client Library Architecture

Client libraries should be layered.

32.1 Core Model

  • Loads ModDef documents.
  • Resolves imports.
  • Provides metadata access.

32.2 Codec Layer

  • Decodes Modbus register responses into typed values.
  • Encodes write values into Modbus payloads.
  • Handles endianness, scaling, enum mapping, strings, bit fields, and composed values.

32.3 Transport Adapter

Defines an interface for Modbus operations:

  • read_coils
  • read_discrete_inputs
  • read_input_registers
  • read_holding_registers
  • write_single_coil
  • write_multiple_coils
  • write_single_register
  • write_multiple_registers

32.4 Device Facade

Generated high-level API for a device profile.

Example:

meter = ExampleMeter(client)
meter.get_frequency()
meter.get_voltage(phase="L1-N")
meter.get_energy_active(direction="import", accumulation="register")

33. Testing and Compliance

A compliance suite should include:

  • Golden files.
  • YAML to JSON equivalence tests.
  • JSON to binary equivalence tests.
  • Binary decoding tests.
  • Invalid document fixtures.
  • Linter fixture tests.
  • Code generation tests.
  • Cross-language decode/encode tests.
  • Optional hardware-in-loop tests.
  1. Define Protobuf schema.
  2. Implement parser and canonical model.
  3. Implement YAML and JSON conversion.
  4. Implement binary .moddef writer and reader.
  5. Implement import resolution.
  6. Implement linter.
  7. Implement codec in one reference language.
  8. Implement core measurand library.
  9. Implement generated clients for one language.
  10. Add SunSpec package mapping.
  11. Add additional language generators.
  12. Publish compliance suite.

35. External References

This section is informational.

  • Modbus Application Protocol Specification, for protocol-level address spaces and function codes.
  • SunSpec Modbus models, for solar, inverter, and storage examples.
  • OCPP measurands and phase values, for semantic measurement naming inspiration, especially phase references such as L1, L2, L3, N, L1-N, L2-N, L3-N, L1-L2, L2-L3, and L3-L1.

36. Design Notes

36.1 Why Not Use OCPP Measurands Directly?

OCPP measurands are useful and well known in EV charging, but they are domain-specific.

ModDef should not make EV charging terminology the universal core model. Instead, ModDef uses a semantic tuple model and provides OCPP-compatible aliases.

36.2 Why Not Encode voltage_l1_n as a Separate Core Measurand?

A flat list becomes hard to scale.

Instead of defining every combination as a separate name:

voltage_l1_n
voltage_l2_n
voltage_l3_n
voltage_l1_l2

ModDef defines:

base_quantity = voltage
phase_ref = L1_N

This is easier to validate, query, and generate.

36.3 Why Keep Protobuf Internal?

Protobuf provides a stable schema and compact binary encoding.

End users can still work with YAML and JSON, while tool authors can generate code and binary files from a canonical schema.

37. Revision History

v0.5 (2026-07-18)

Driven by moddef#1: the Iskra WM3M4/WM3M4C meter stores its instantaneous measurements in the T5/T6 format — a signed 8-bit decade exponent embedded in the high byte of the same 32-bit word as a 24-bit mantissa — which v0.4 could not express. The Eaton PXM 4000/6000/8000 "GENERAL FORMAT" (8-bit exponent + 56-bit mantissa in one 4-word value) confirmed the pattern is cross-vendor; the fix is a parameterised bit window rather than a fixed-width mode.

Added:

  • Embedded (same-word) mantissa/exponent (§14.2): composed sub-mappings may carry bit_offset + bit_length to window disjoint bit ranges of a shared word. Sign extension uses bit_length; the sub-mapping's storage_type supplies signedness.
  • Composed↔composed overlap exemption (§14, §28.3): parent read windows of composed points may overlap each other, unblocking interleaved mantissa/exponent register pools (Iskra non-reset energy totals). Overlap with non-composed points remains MDE301.
  • Composed sub-mapping validation (§28.8): parent length_words required, window-relative containment, bit-window fit (MDE303), and mantissa/exponent disjointness (new MDE408).

Schema consistency fix: Mapping.storage_type — the §14 examples had always shown storage_type on composed sub-mappings, but the field was missing from the §27 schema, so those examples did not parse. It is now a real field and determines sub-value signedness/width.

(This document retains its v0.4 filename; v0.5 is an additive draft revision of it.)

v0.5 — commands construct (2026-07-21)

Driven by moddef#2: two blessed devices need multi-step register procedures, not points. The Bauer BSM's OCMF signing models were "intentionally not modeled as points" (write Meta1/2/3 → arm a snapshot-status register → poll until Valid → read a 496-word signed blob), and the Iskra WM3M4's billing/signature flow ends by reading an Output Message whose length is only known from a separate Output Message Length register.

Added:

  • Commands (§11.7, DeviceProfile.commands): device-scoped multi-step procedures with typed params, strictly linear steps (write param / write trigger / poll-until-condition / read-and-bind), and typed results assembled from read-step bindings. command_ref is carried as a free-form role tag for consumer dispatch; it is not resolved or validated. A stdlib package of bindable command signatures was evaluated and rejected: on Modbus, signed-metering payloads (OCMF) are opaque string/blob windows composed by the caller from configuration and runtime state, so no declarative binding can reproduce them.
  • Dynamic read length (§11.7.1, Mapping.length_ref): the effective word count of a read resolved from another point at read time, clamped by length_words when both are set. A point-level feature (the read-length analog of scale_ref), usable inside and outside commands.
  • Validation (§28.9): MDE502MDE508 and MDW501 for command structure; MDE407 (in-profile ref resolution) is now implemented, generalized across scale_ref/selector_ref/available_if/ count_ref/length_ref.

v0.4 (2026-06-04)

Driven by validating v0.3 against 136 real device Modbus specifications across energy meters, solar inverters, battery storage, EV chargers, and HVAC heat pumps. The full analysis (coverage, per-device verdicts, and the ranked gap list) is in research/MODDEF-GAP-ANALYSIS.md; per-file reports are in research/_gap_reports/. Of 90 real Modbus register specs, 16 were fully expressible in v0.3 and 91 were partial, collapsing to the ~10 recurring constructs added here.

Added (blockers, without which common devices decode incorrectly):

  • Dynamic (register-referenced) scaling (§10.4, ScaleRef): value = raw · 10^SF where SF is another register. Mandatory for SunSpec; also used by Eaton IQ, Janitza, Carrier. Highest-impact change; unblocks essentially every SunSpec device.
  • Unavailable / sentinel values (§8.4, na_values): declare raw patterns such as 0x8000/0xFFFF/NaN that mean "no data" rather than a reading.
  • Dynamic / discovered addressing (§7.3, Discovery): SunSpec SunS model-chain walk and model-relative offsets.
  • Conditional point presence (§17.1, available_if).

Added (important, broad and cross-domain):

  • Register-field structs (§13.1, RegisterField): typed sub-fields with their own type/scale/unit (HIWORD/LOWWORD, packed BCD, version tuples, IPv4).
  • Flag sets (§13.2, FLAGS): multi-flag status registers.
  • Date/time values (§8.5, DATETIME + DateTimeSpec): epoch s/ms, packed BCD, split fields.
  • Repeating arrays with stride / count reference (§14.1, stride_words + count_ref): multi-string PV, per-cell battery.
  • Value/scale/unit selected by another register (§10.5, SelectorRef).

Added (moderate / localized):

  • Extended storage widths U24, U48, S48 and a sign_magnitude flag (§8.2).
  • Distinct write encoding (§11.5) and packed command arguments (§11.6).

Schema consistency fixes (prose features the v0.3 §27 draft had omitted): write semantics (WriteSemantics/WriteConstraints), string encoding (StringEncoding), composed kind, per-point function-code constraints, DeviceProfile.firmware_versions, and RegisterBlock.overlay are now present in the §27 schema.

Explicitly out of scope (documented in §3, not added): non-Modbus transports: Tesla (DNP3/REST), sonnen (REST), Pylontech US (proprietary ASCII), Vaillant/Bosch (eBUS/EMS), Easee/Zaptec/EVBox/ChargePoint (OCPP/REST), and BACnet-only devices. ModDef remains a Modbus description format.

v0.3

Initial public draft: document model, four address spaces, logical/storage type split, endianness, rational scaling, access modes, enums, bit fields, composed values, strings, device profiles, register blocks, variants, imports and packages, the core measurand tuple system and catalog, the §27 Protobuf schema draft, validation/linter rules, tooling, and compliance testing.