Summary
Add a general command / procedure construct to moddef: a named, parameterized, multi-step register interaction with typed inputs, ordered steps (write / poll-until / read), dynamic-length reads, and typed results. Today moddef is stateless and per-point (read point → decode; write point → encode). Several real device operations — most notably signed metering transactions for German Eichrecht (the EVerest powermeter interface's start_transaction / stop_transaction) — are inherently stateful, multi-step procedures that moddef cannot describe.
This is the domain-neutral core half of the proposal. The EV/OCMF-specific command signatures belong in a stdlib binding — see the companion issue (stdlib:ocmf-transaction). Related: #1 (embedded-exponent), which surfaced the same class of "meters need more than fixed per-point scaling."
Motivation
EVerest's GenericPowermeter and moddef solve the same problem (declaratively map a meter's Modbus registers to semantic quantities), but neither supports transactions, which are required for Eichrecht. The EVerest powermeter interface defines:
start_transaction(TransactionReq) -> TransactionStartResponse
stop_transaction(transaction_id) -> TransactionStopResponse (returns the OCMF string)
The meter (or its signing component) performs the cryptography; the driver only orchestrates register I/O. Two devices already in the ModDefOrg/devices registry exercise the two hardware styles:
- Bauer BSM (SunSpec signing meter): write identification into
Meta1/2/3 string registers → trigger a Start/End snapshot → read back the OCMF Signed model instance (64903) as a string. Public key is length-prefixed (NPK registers, BPK bytes, then the PK repeating block).
- Iskra WM3M4: write the billing dataset → write the
Command Register (47051) → poll the Signature Status Register (47052) until ready → gather the signature spread across 120+ registers (the manual states it cannot be read in a single request).
Both reduce to: scatter a typed payload into registers → issue a trigger → poll a status register → gather a variable-length result.
What's missing in moddef today
moddef already has WriteBehavior.COMMAND_TRIGGER (§11.3) but nothing behind it — no way to declare a command, its parameters, its steps, or its result. Specifically:
- Typed command inputs (struct-scatter). A command that accepts a structured payload and writes each field to a register / string region. moddef can read composites (
ComposedMapping, RegisterField, strings) but has no symmetric struct-write and no parameterized operation.
- Ordered steps with poll/wait.
write → poll(status == READY, interval, timeout) → read. No sequencing or "wait until register matches" primitive.
- Dynamic-length reads (
length_ref). Result length comes from another register (Bauer BPK/NPK; Iskra status-gated 120+ regs). moddef strings/arrays are fixed or max-length only.
- Typed command results. Map the gathered reads back to a typed struct (
{ status, ocmf, signed_meter_value }).
Proposed core additions (domain-neutral)
Three additions, built on existing constructs (COMMAND_TRIGGER, ComposedMapping, string_encoding, stride_words, na_values):
1. commands: on a device
A named procedure with typed params, ordered steps, and typed results. command_ref optionally binds a well-known signature imported from an stdlib package (keeps the core neutral).
2. poll step primitive
read a point → test a condition → repeat at interval until match or timeout.
3. length_ref on a mapping
Element/byte count read from another point (reuses the stride_words array machinery from §14.1).
Schema sketch (proto-level, illustrative)
message Command {
string command_id = 1;
string command_ref = 2; // optional: stdlib signature URI
repeated CommandParam params = 3;
repeated CommandStep steps = 4;
repeated CommandResult results = 5;
}
message CommandParam { // one input field -> where it is written
string field = 1;
StorageType storage_type = 2;
ValueType value_type = 3;
Mapping mapping = 4; // register / string region / composed target
bool required = 5;
}
message CommandStep {
oneof step {
WriteStep write = 1; // write param(s) or a literal trigger value
PollStep poll = 2; // read + condition + interval_ms + timeout_ms
ReadStep read = 3; // read a point (may use length_ref) into a name
}
}
message PollStep {
string point_id = 1;
Condition until = 2; // eq / ne / mask / range
uint32 interval_ms = 3;
uint32 timeout_ms = 4;
}
message CommandResult { // gathered value -> output field
string field = 1;
string from = 2; // step name or point_id
ValueType value_type = 3;
EnumRef enum_ref = 4;
}
// Mapping gains: optional string length_ref = N (point_id giving element/byte count)
Prototype (end-to-end against the two registry devices)
This is item (3) from the design discussion: prove the shape against both hardware styles before it lands in the spec. Pseudocode only — field names indicative.
(a) Profile fragment — Bauer BSM (self-signing SunSpec; snapshot + read model)
commands:
- command_id: start_transaction
command_ref: moddef:stdlib:ocmf-transaction:1.0.0 # see companion issue
params:
- { field: identification_data, storage_type: STRING_ASCII,
mapping: { space: HOLDING_REGISTER, offset: 40279, length_words: 70,
string_encoding: { charset: ASCII } } } # Meta1
- { field: tariff_text, storage_type: STRING_ASCII,
mapping: { space: HOLDING_REGISTER, offset: 40349, length_words: 50 } } # Meta2
steps:
- write: { trigger_point: snapshot_command, value: START_SNAPSHOT }
- poll: { point: snapshot_status, until: { eq: VALID },
interval_ms: 200, timeout_ms: 5000 }
- read: { point: ocmf_signed_start, into: ocmf } # OCMF model 64903 string
results:
- { field: status, from: snapshot_status, enum_ref: transaction_status }
- { field: signed_meter_value, from: ocmf }
- command_id: stop_transaction
command_ref: moddef:stdlib:ocmf-transaction:1.0.0
steps:
- write: { trigger_point: snapshot_command, value: END_SNAPSHOT }
- poll: { point: snapshot_status, until: { eq: VALID }, interval_ms: 200, timeout_ms: 5000 }
- read: { point: ocmf_signed_end, into: ocmf }
results:
- { field: status, from: snapshot_status, enum_ref: transaction_status }
- { field: signed_meter_value, from: ocmf }
(b) Profile fragment — Iskra WM3M4 (write dataset → command → poll → gather)
commands:
- command_id: start_transaction
command_ref: moddef:stdlib:ocmf-transaction:1.0.0
params:
- { field: transaction_id, mapping: { offset: 7060, length_words: 8, storage_type: STRING_ASCII } }
- { field: identification_data, mapping: { offset: 7068, length_words: 10, storage_type: STRING_ASCII } }
steps:
- write: { trigger_point: command_register, value: START_SIGN } # 47051
- poll: { point: signature_status, until: { eq: READY }, # 47052
interval_ms: 200, timeout_ms: 8000 }
- read: { point: signature_blob, into: signature } # length_ref, 120+ regs
results:
- { field: status, from: signature_status, enum_ref: transaction_status }
- { field: signed_meter_value, from: signature }
# where signature_blob uses a dynamic-length read:
points:
- point_id: signature_blob
storage_type: BYTES_RAW
value_type: { primitive: BYTES }
mapping: { space: HOLDING_REGISTER, offset: 7100, length_ref: signature_length }
(c) Executor pseudocode (runtime / adapter)
func run_command(profile, cmd_id, inputs) -> results:
cmd = profile.commands[cmd_id]
# 1. scatter typed params into registers
for p in cmd.params:
if p.required and p.field not in inputs: error(NOT_SUPPORTED, "missing " + p.field)
if p.field in inputs:
regs = encode(inputs[p.field], p.storage_type, p.value_type, p.mapping)
transport.write(p.mapping.space, p.mapping.offset, regs)
# 2. run steps in order
named = {}
for step in cmd.steps:
switch step:
write: transport.write(resolve(step.trigger_point), step.value)
poll:
deadline = now() + step.timeout_ms
loop:
v = decode(transport.read(step.point_id))
if condition_holds(v, step.until): break
if now() > deadline: error(UNEXPECTED_ERROR, "poll timeout on " + step.point_id)
sleep(step.interval_ms)
read:
n = step.point.length_ref ? decode(read(step.point.length_ref)) : step.point.length_words
named[step.into] = decode(transport.read(step.point.offset, n), step.point)
# 3. assemble typed results
out = {}
for r in cmd.results:
raw = named.get(r.from) ?? decode(transport.read(r.from))
out[r.field] = coerce(raw, r.value_type, r.enum_ref)
return out
An EVerest adapter (ModdefPowermeter, a successor to GenericPowermeter) calls run_command("start_transaction", TransactionReq{...}) and maps out onto TransactionStartResponse. Capability discovery is trivial: profile.commands has "start_transaction" → advertise transaction support; otherwise the adapter degrades to plain metering.
Scope boundaries
- No crypto in moddef. The meter signs; moddef only scatters params, triggers, polls, and gathers the resulting blob/string. This is what keeps moddef a device-description language rather than a charging protocol.
- No OCMF content validation. moddef transports the string; the adapter/back-office validates it.
- Meters that do not self-sign cannot satisfy transactions at the device level — the adapter must fall back via capability discovery.
Validation rules to add (sketch)
- Every
command.params[].field / results[].field must resolve against the bound command_ref signature (if present).
poll.point_id, read.point, length_ref must reference existing points.
length_ref target must be an integer point; the resulting read must stay within the addressable space.
- Command param write targets must not overlap declared read-only blocks (or must be flagged).
Open questions
- Should steps allow branching / retry, or is linear + poll enough for real meters? (Both examples are linear.)
length_ref in registers vs bytes — Bauer exposes both (NPK registers, BPK bytes); pick one canonical and derive.
- Should
commands live on the device or the document, given a profile can hold multiple devices?
Summary
Add a general command / procedure construct to moddef: a named, parameterized, multi-step register interaction with typed inputs, ordered steps (write / poll-until / read), dynamic-length reads, and typed results. Today moddef is stateless and per-point (read point → decode; write point → encode). Several real device operations — most notably signed metering transactions for German Eichrecht (the EVerest
powermeterinterface'sstart_transaction/stop_transaction) — are inherently stateful, multi-step procedures that moddef cannot describe.This is the domain-neutral core half of the proposal. The EV/OCMF-specific command signatures belong in a stdlib binding — see the companion issue (stdlib:ocmf-transaction). Related: #1 (embedded-exponent), which surfaced the same class of "meters need more than fixed per-point scaling."
Motivation
EVerest's
GenericPowermeterand moddef solve the same problem (declaratively map a meter's Modbus registers to semantic quantities), but neither supports transactions, which are required for Eichrecht. The EVerestpowermeterinterface defines:start_transaction(TransactionReq) -> TransactionStartResponsestop_transaction(transaction_id) -> TransactionStopResponse(returns the OCMF string)The meter (or its signing component) performs the cryptography; the driver only orchestrates register I/O. Two devices already in the
ModDefOrg/devicesregistry exercise the two hardware styles:Meta1/2/3string registers → trigger a Start/End snapshot → read back theOCMF Signedmodel instance (64903) as a string. Public key is length-prefixed (NPKregisters,BPKbytes, then thePKrepeating block).Command Register(47051) → poll theSignature Status Register(47052) until ready → gather the signature spread across 120+ registers (the manual states it cannot be read in a single request).Both reduce to: scatter a typed payload into registers → issue a trigger → poll a status register → gather a variable-length result.
What's missing in moddef today
moddef already has
WriteBehavior.COMMAND_TRIGGER(§11.3) but nothing behind it — no way to declare a command, its parameters, its steps, or its result. Specifically:ComposedMapping,RegisterField, strings) but has no symmetric struct-write and no parameterized operation.write → poll(status == READY, interval, timeout) → read. No sequencing or "wait until register matches" primitive.length_ref). Result length comes from another register (BauerBPK/NPK; Iskra status-gated 120+ regs). moddef strings/arrays are fixed or max-length only.{ status, ocmf, signed_meter_value }).Proposed core additions (domain-neutral)
Three additions, built on existing constructs (
COMMAND_TRIGGER,ComposedMapping,string_encoding,stride_words,na_values):1.
commands:on a deviceA named procedure with typed params, ordered steps, and typed results.
command_refoptionally binds a well-known signature imported from an stdlib package (keeps the core neutral).2.
pollstep primitiveread a point → test a condition → repeat at interval until match or timeout.3.
length_refon a mappingElement/byte count read from another point (reuses the
stride_wordsarray machinery from §14.1).Schema sketch (proto-level, illustrative)
Prototype (end-to-end against the two registry devices)
This is item (3) from the design discussion: prove the shape against both hardware styles before it lands in the spec. Pseudocode only — field names indicative.
(a) Profile fragment — Bauer BSM (self-signing SunSpec; snapshot + read model)
(b) Profile fragment — Iskra WM3M4 (write dataset → command → poll → gather)
(c) Executor pseudocode (runtime / adapter)
An EVerest adapter (
ModdefPowermeter, a successor toGenericPowermeter) callsrun_command("start_transaction", TransactionReq{...})and mapsoutontoTransactionStartResponse. Capability discovery is trivial:profile.commands has "start_transaction"→ advertise transaction support; otherwise the adapter degrades to plain metering.Scope boundaries
Validation rules to add (sketch)
command.params[].field/results[].fieldmust resolve against the boundcommand_refsignature (if present).poll.point_id,read.point,length_refmust reference existing points.length_reftarget must be an integer point; the resulting read must stay within the addressable space.Open questions
length_refin registers vs bytes — Bauer exposes both (NPKregisters,BPKbytes); pick one canonical and derive.commandslive on the device or the document, given a profile can hold multiple devices?