Code Generation with ifwFcfSimTmcExport

Writing a device simulator entirely by hand (as in the implementation chapter) is instructive, but for a real PLC device most of the code is boilerplate: the OPC UA namespace, the data structures, the RPC method stubs and the state-machine scaffolding all follow directly from the PLC’s interface. ifwFcfSimTmcExport generates that boilerplate — roughly 90% of a simulator — leaving you to fill in only the business logic.

What “TMC” means

TMC stands for TwinCAT Module Class. It is the file a Beckhoff TwinCAT PLC project exports to describe a module’s interface: its data types, variables and method signatures. Because the FCF standard devices run on TwinCAT PLCs, the .tmc file is the authoritative description of a device’s OPC UA interface — so generating the simulator from it guarantees the simulator mirrors the real PLC.

ifwFcfSimTmcExport reads a model file (YAML, TOML or JSON) that references the TMC file (and, optionally, an SCXML state-machine file) and runs a template to emit the simulator package. The same mechanism can also generate other artefacts (client code, etc.) via different templates.

Prerequisites

Everything to generate simulator code is shipped with the ifw-fcfsim package. It includes:

  • A binary containing the generator engine: extract information from TMC and scxml and run the template engines

  • Templates. They are written in scriban. They contain the template description of generated files but also configurable functions to transform extracted data. In fact some templates are just a set of these functions.

  • ifwFcfSimTmcExport Is the orchestration command-line tool to run the generator engine with the templates and the model file.

To list all templates available:

ifwFcfSimTmcExport list                 # list installed templates / generators

Most of the documentation is self-contained in the command line:

ifwFcfSimTmcExport help                 # command-line usage for every subcommand
ifwFcfSimTmcExport help export          # usage for one subcommand
ifwFcfSimTmcExport doc                  # in-terminal documentation (e.g. templates)

Subcommands at a glance:

  • export — generate code from a model (YAML, TOML or JSON) file. The main command.

  • run — run the generator with command-line arguments only.

  • install — install a template package or the generator. Not available in ELT-DevEnv;

    in ELT-DevEnv, templates are installed by waf.

  • list — list installed packages, templates or generators.

  • doc — detailed information about something (e.g. a template).

  • dumpcfg — print a default tmc_export config file to stdout.

The model file

ifwFcfSimTmcExport is driven by a model file (a small plain TOML, YAML or JSON file). The model points at the TMC file (and an optional SCXML state-machine file) and selects which template(s) to run. Given a directory containing:

- model.toml
- Lamp.tmc
- lamp.scxml.xml

a model file for a lamp simulator looks like:

name = "Lamp"
extraction_root = "MAIN.Lamp001"
tmc_file = "./Lamp.tmc"
scxml_file = "./lamp.scxml.xml"

templates = ["simulator"]

[configs.base]
   "$include" = ["../model_config.toml"]
   NamespacePath = 'tins/fcs1/sim/lamp'

Where ../model_config.toml contains the shared default configuration across all devices. Included in place by the $include directive.

From that extraction point the generator pulls everything it needs: enumerators, the properties exposed to the OPC UA interface, the classes (structures / function blocks) behind non-basic properties, and the methods exposed as OPC UA (RPC) calls with their input/output arguments.

Please refer to the online documentation for more information about the model file and how to set up template configurations (the configs section):

ifwFcfSimTmcExport doc model
ifwFcfSimTmcExport doc config

Note

If no .tmc file is available, ifwFcfSimTmcExport can use a model written directly in the model configuration file. Run ifwFcfSimTmcExport doc custom_model for details.

Running the generation

Run export with the model file:

ifwFcfSimTmcExport export model.toml

The output root is the directory containing model.toml unless overridden. Useful options (see ifwFcfSimTmcExport help export for the full list):

  • -o / --output_dir — where to write the generated package (defaults to the model file’s directory).

  • --template <name> — force a specific template (overrides the one named in the model file).

  • --dry — dry run: Export is done in a temp directory, the output directory is

    left un-touched. However –dry copy the output directory inside the temp directory, run the export and show all the diff.

  • -v <LEVEL> / --debug — control verbosity.

The generated package is a complete, type-checked Python project that compiles as-is. For a shutter device it looks like:

shutter/
├── resource/
│   ├── config/.../shutter/
│   │   ├── shutter.namespace.yaml      # OPC UA namespace profile
│   │   └── shutter.scxml.xml           # state machine
│   ├── config/.../example/shutter.cfg.yaml
│   └── schema/.../shutter/shutter.schema.yaml
├── src/.../shutter/
│   ├── bl.py                           # business logic  <- YOU EDIT THIS
│   ├── sim.py                          # interface + factory
│   ├── gen/                            # generated, do NOT edit
│   │   ├── isim.py
│   │   ├── sim_data.py
│   │   └── sim_engine.py
│   └── py.typed
├── test/
│   ├── test_shutter_resources.py
│   └── test_shutter_sequence.py
└── wscript

The package is split into two kinds of file, so it can be regenerated from an updated TMC without losing your work:

  • Always-generated files (replaced on every export — do not edit):

    • gen/sim_data.py — the raw enumerators and classes as they are in the TC3 project (names normalised to Python conventions).

    • gen/sim_engine.py — declares the simulator engine, reflecting the PLC Function Block and the state machine; instantiates the business- logic class from bl.py and runs its methods with logging wrapped around them.

    • gen/isim.py — the interface/abstract class describing what the business-logic class must implement.

  • Generate-once files (protected — never overwritten by a later export): chiefly bl.py, where the business logic lives. sim.py is the device simulator API, and declares the Interface for the OPC-UA server as well as the Interface factory. In most cases sim.py does not need to be edited.

    Also, files related to the config resource (configng schema and an example) are generated once. They can be edited along with the action each simulator parameter will have on the business logic. Simulator parameters will be served to the business logic class through the load_cfg(data) method.

So regenerating after a TMC change refreshes gen/ while leaving your bl.py untouched.

Filling in the business logic

The generator produces bl.py with method stubs that raise NotImplementedError(). Your job is to translate what the PLC does into Python. There are four kinds of thing to complete.

State mapping. The generator makes a best guess at mapping SCXML state names to the device’s State / Substate enumerators. Check it — some devices need corrections:

S, SS = ShutterState, ShutterSubstate
state_mapping: dict[str, tuple[int, int]] = {
    "On::Operational":           (S.OP,    SS.NONE),
    "On::Operational::Open":     (S.OP,    SS.OP_OPEN),
    "On::Operational::Opening":  (S.OP,    SS.OP_OPENING),
    "On::NotOperational::Ready": (S.NOTOP, SS.NOTOP_READY),
    # ...
}

Because the generated code is type-checked, mypy catches most of the wrong best-guesses for you — run it after waf build install:

mypy -p tins.fcs1.sim.lamp --strict --ignore-missing-imports
  [...]/lamp/bl.py:54: error: "type[LampSubstate]" has no attribute
    "OP_SWITCHINGON"  [attr-defined]

i.e. correct OP_SWITCHINGON to OP_SWITCHING_ON (as defined in the PLC), and so on. Then work through the remaining # TODO markers and NotImplementedError raises. If one wishes to use other ways to map scxml states to the device states, it is possible to override the listen_state method of the business logic class.

RPC methods. Each PLC method comes through as a stub; implement the device’s reaction. Raise an RpcError rather than returning an error code — the OPC UA server engine catches it and returns the correct status to the client:

def open(self) -> int:
    """open Shutter"""
    self.check_local()
    if self.stat.state != ShutterState.OP:
        raise self.rpc_error(ShutterRpcError.NOT_OP)
    if self.stat.substate == ShutterSubstate.OP_CLOSING:
        raise self.rpc_error(ShutterRpcError.STILL_CLOSING)
    self.set_command(ShutterCommand.OPEN)
    return self.ok()

The cyclic task (``next``). next runs once per simulated PLC cycle. Typically it inspects the controller structure and sends events to the state machine, mimicking the PLC:

async def next(self) -> None:
    self.handle_command()

def handle_command(self) -> None:
    if not self.ctrl.execute:
        return
    command = self.ctrl.command
    self.ctrl.execute = False
    match command:
        case ShutterCommand.INIT:
            self.schedule(ShutterEvent.Init, "Init Shutter Device")
        case ShutterCommand.OPEN:
            self.schedule(ShutterEvent.Open, "Open Shutter")
        # ...

Activities and actions. Generated from the SCXML file, these run the state machine’s threaded work. A typical activity simulates a movement taking some time, then signals completion:

def opening_activity(self, handler: sm.IActivityHandler) -> None:
    start = time.time()
    while (time.time() - start) < self.config.sim_delay and handler.is_running():
        time.sleep(0.010)
    if not handler.is_running():
        return
    handler.send_internal_event(sm.Event(dfn.ShutterEvent.IsOpen))
    handler.stop()

Configuration. Simulation behavior can be altered by user configuration as read from config configuration by the simulator interface. These configurations are loaded into the business logic object with the load_cfg(config_dict) method. To keep the code clean and type checkable a config dataclass is also generated and can be edited.

Running and testing

The generated package builds with waf like any other simulator, and ships with an example config and tests:

waf configure && waf build && waf install
fcfsimServer --cfg <generated example cfg> --port 4840

See Getting Started for running it and connecting a client, and the implementation chapter / FCFsim SDK Description for the APIs the business logic uses (RpcError providers, the state-machine decorators, tasks and runners).