Specification v0.5
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
commandswith 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:
- The physical Modbus mapping, where a value is located and how it is encoded.
- 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()orget_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_powerenergy_active_registervoltagefrequencystate_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.kindisSUNSPEC, point offsets are interpreted asmodel_relative_offsetwithin the resolved model; the absolute address is computed by the codec after the model chain is walked. model_relative_offsetis 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 103Wat offset 14, as in the example above).anchor_candidateslists the register addresses to probe for theSunSmarker (commonly 0, 40000, or 50000).- A block with no
discoveryuses 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:
BOOLINT32UINT32INT64UINT64FLOAT32FLOAT64DECIMALSTRINGBYTESENUMSTRUCTARRAYDATETIME(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:
BITU16S16U32S32U64S64IEEE754_F32IEEE754_F64STRING_ASCIISTRING_UTF8BYTES_RAWBCDFIXED_POINTCOMPOSEDU24,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_typestorage_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:
0x8000forint16,0x80000000forint32,0xFFFF/0xFFFFFFFFfor unsigned "not accumulated", and a NaN for floats. - ABB:
0x7FFF…(max positive); Carlo Gavazzi:0x7FFF→ displayEEEE.
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_valueslists 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_typeprofile 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 aU32/U64.PACKED_BCD_DATETIME: BCD-packedYYYYMMDDhhmmssacross 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_ENDIANLITTLE_ENDIAN
9.2 Word Order
Word order describes ordering of 16-bit words in multi-word values.
Values:
WORD_BIG_ENDIANWORD_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:
- Read raw register or bit data.
- Decode using storage type, width, sign, byte order, and word order.
- Apply scale.
- Apply offset.
- Apply clamp, if defined.
- Apply enum mapping, if defined.
- 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) orscale_refmay be set. mode: POW10⇒value = raw · 10^ref(the SunSpec convention;refis a signed integer, oftenint16).mode: MULTIPLY⇒value = 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_ONLYWRITE_ONLYREAD_WRITECOMMAND
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_behaviorconfirmation_pointallowed_valuesrequires_enable_point
11.3 Write Behaviors
Supported write behaviors:
DIRECTMOMENTARYLATCHMASKEDCOMMAND_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. EachCommandParamhas afieldname,storage_type,value_type, and its ownMapping(the registers the value is written to). Arequiredparam 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 readpoint_id(atinterval_ms) until theuntilcondition holds, failing aftertimeout_ms. Conditions compare the raw pre-transform integer value:EQ/NEagainstvalue,MASKas(raw & mask) == value,RANGEasmin <= raw <= max(MDE507for structurally invalid conditions).read— readpoint_id(honouringlength_ref, below) and bind the decoded value to the nameinto.
results— the typed output record. EachCommandResult.fieldis sourcedfroma read-step binding (or a point id, read fresh after the steps if never bound).value_type/enum_refgive 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_refresolves 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 uselength_reffor 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 onlength_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_wordscovering 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
MDE301with 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_offsetcounts from the LSB of the whole assembled sub-mapping window (afterbyte_order/word_order), exactly as §13.1RegisterField.- A sub-value with a bit window sign-extends from
bit_length(not the storage width) when itsstorage_typeis 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:
NULLSPACENONE
Recommended termination modes:
FIXED_LENGTHNULL_TERMINATED
16. Device Profiles
A device profile describes one device model, family, or reusable abstract device.
Fields:
device_idvendormodelfamilydescriptionfirmware_versionssupported_transportsdefault_unit_idblocksvariants
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_idnamespacestart_offsetlength_wordspoints
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_powerreactive_powerapparent_powerenergy_activeenergy_reactiveenergy_apparentvoltagecurrentfrequencypower_factortemperaturestate_of_chargestate_of_healthdevice_statusalarm_status
22.2 Direction
Direction is used for quantities where flow direction matters.
Recommended values:
NONEIMPORTEXPORTNETCHARGEDISCHARGEFORWARDREVERSE
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:
NONETOTALAVERAGEMINIMUMMAXIMUMINSTANTANEOUS
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:
UNSPECIFIEDGRIDLOADINLETOUTLETBATTERYPVINVERTEREVCABLEBODYINTERNALEXTERNAL
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:
INSTANTANEOUSREGISTERINTERVALLIFETIMESESSIONDAILYMONTHLY
Example:
base_quantity: energy_active
direction: IMPORT
accumulation: REGISTER
22.7 Context
Context describes why or when a value is sampled.
Recommended values:
UNSPECIFIEDSAMPLE_PERIODICSAMPLE_CLOCKTRANSACTION_BEGINTRANSACTION_ENDINTERRUPTION_BEGININTERRUPTION_ENDTRIGGEREDMANUAL
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, directionIMPORT, phase_refL1active_power, directionIMPORT, phase_refL2active_power, directionIMPORT, phase_refL3active_power, directionIMPORT, aggregationTOTAL
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: L1should not be treated as equivalent tophase_ref: L1_Nunless 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, orL3_L1. - Line-to-neutral voltage values must use
L1_N,L2_N, orL3_N. - Current and per-phase power normally use
L1,L2, orL3, notL1_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:
types.proto: primitive and value types, enums, transportsmapping.proto: physical mapping, transforms, fields, strings, write semanticscommand.proto: multi-step command procedures (§11.7)device.proto: device profiles, register blocks, points, variantsmeasurand.proto: the measurand model and aliasesdocument.proto: the top-level document and imports
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
transformmust set at most one ofscaleandscale_ref.- A
scale_ref/selector_ref/available_if/count_ref/length_reftarget must resolve to a point in the same device profile (MDE407). - A point whose
value_type.primitiveisDATETIMEmust definedatetime. - A point whose
value_type.kindisflagsmust define at least one bit. na_valuesraw 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 = SUNSPECmust usemodel_relative_offseton its points (not absoluteoffset). stride_wordsmust 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_idmust 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
requiredCommandParammust be written by at least oneWriteStep(MDE504). - A
length_reftarget must decode to an integer primitive (MDE505). - The
length_refgraph must be acyclic, including self-reference (MDE506). - A
PollStep.untilcondition must be structurally valid for itsop: op set,RANGEwithmin <= max,MASKwith a non-zero mask (MDE507). - A
WriteStepmust not target aREAD_ONLYpoint (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_coilsread_discrete_inputsread_input_registersread_holding_registerswrite_single_coilwrite_multiple_coilswrite_single_registerwrite_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.
34. Recommended Implementation Roadmap
- Define Protobuf schema.
- Implement parser and canonical model.
- Implement YAML and JSON conversion.
- Implement binary
.moddefwriter and reader. - Implement import resolution.
- Implement linter.
- Implement codec in one reference language.
- Implement core measurand library.
- Implement generated clients for one language.
- Add SunSpec package mapping.
- Add additional language generators.
- 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, andL3-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_lengthto window disjoint bit ranges of a shared word. Sign extension usesbit_length; the sub-mapping'sstorage_typesupplies 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_wordsrequired, window-relative containment, bit-window fit (MDE303), and mantissa/exponent disjointness (newMDE408).
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 typedparams, strictly linearsteps(write param / write trigger / poll-until-condition / read-and-bind), and typedresultsassembled from read-step bindings.command_refis 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 bylength_wordswhen both are set. A point-level feature (the read-length analog ofscale_ref), usable inside and outside commands. - Validation (§28.9):
MDE502–MDE508andMDW501for command structure;MDE407(in-profile ref resolution) is now implemented, generalized acrossscale_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^SFwhereSFis 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 as0x8000/0xFFFF/NaN that mean "no data" rather than a reading. - Dynamic / discovered addressing (§7.3,
Discovery): SunSpecSunSmodel-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,S48and asign_magnitudeflag (§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.