Skip to content

Generic command/procedure construct (multi-step register operations, poll, length_ref) #2

Description

@suda

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:

  1. 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.
  2. Ordered steps with poll/wait. write → poll(status == READY, interval, timeout) → read. No sequencing or "wait until register matches" primitive.
  3. 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.
  4. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions