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.
ifwFcfSimTmcExportIs 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 defaulttmc_exportconfig 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 isleft 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 frombl.pyand 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.pyis the device simulator API, and declares the Interface for the OPC-UA server as well as the Interface factory. In most casessim.pydoes 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).