Implementing a Device Simulator
As mentioned before, FcfSim is built as a framework, therefore there are several ways to implement a Device Simulator. However, using the offered ServerInterface class and some decorators for the class and methods will fulfill most of the needs. Below we will show an implementation using ServerInterface; a more step-by-step way can be understood by code introspection or in the SDK chapter (FCFsim SDK Description).
First thing to know, everything needed to implement a device simulator is located in one api module:
from ifw.fcfsim import api as dvs
Note: here dvs stands for DeVice Simulation; choose any alias you prefer.
Implement Simulator for fcfsimServer
As an example we will make a simulator for a simple fictitious Switch device.
The fcfsimServer command-line tool relies on a configuration file that contains any desired key/value pairs to run the simulation properly. The only requirement for the fcfsimServer app is that the config file contains a “factory” keyword which points to a python function. This factory function will build a ServerInterface instance, e.g.:
factory: fcs1.simulators.switch:build_switch_interface
This will load the module fcs1.simulators.switch and look for
build_switch_interface with the signature f(cfg: dict) -> IServerInterface,
where the cfg is a dictionary containing the rest of the configuration file.
Example of a factory:
from ifw.fcfsim import api as dvs
import typing
def build_switch_interface(
cfg_dict: dict[str,typing.Any]
) -> dvs.IServerInterface:
...
The rest of the tutorial here explains how to build the ServerInterface.
To make a proper simulator we need two mandatory things:
populate the OPC-UA server with nodes and RPC methods.
make the business logic interact with the server.
What fcfsim offers is:
easy ways to build an OPC-UA namespace profile
an automatic connection between business logic (attributes and methods) and the OPC-UA server.
Optionally:
build extra threads to take care of some simulation behavior
build and run an scxml4py state machine.
Below is an example where the OPC-UA profile is defined within python classes using typing annotations for attributes and decorators for (RPC) methods.
Simple Simulator
from ifw.fcfsim.api import opcua as ua
from typing import Annotated, Any
@ua.node.attrconnect
class SwitchStat:
"""Switch Status class"""
state: Annotated[int, ua.Ua("nState", ua.Int16)] = 0
state_text: Annotated[str, ua.Ua("sState")] = "off"
counter: Annotated[int, ua.Ua("nCounter")] = 0
class SwitchEngine:
stat: Annotated[SwitchStat, ua.Ua(access='r')]
def __init__(self):
self.stat = SwitchStat()
@ua.uamethod("RPC_TurnOn")
def turn_on(self) -> int:
self.stat.state = 1
self.stat.state_text = "on"
return 0 # no error
@ua.uamethod("RPC_TurnOff")
def turn_off(self) -> int:
self.stat.state = 0
self.stat.state_text = "off"
return 0 # no error
def load_cfg(self, cfg: dict[str,Any]) -> None:
"""load configuration (e.g. from configng)"""
if cfg.get("auto_on", False):
self.stat.state = 1
self.stat.state_text = "on"
def build_switch_interface(
cfg_dict: dict[str,Any]
) -> ua.IServerInterface[SwitchEngine]:
switch = SwitchEngine()
switch.load_cfg(cfg_dict)
profile = ua.extract_profile(SwitchEngine)
return ua.ServerInterface(
cfg_dict["prefix"], switch, profile
)
That’s it: this is a simulator with two nodes and two RPC methods.
Some notes about the code:
@ua.node.attrconnectis a class decorator. It allows executing callback methods when attributes are changed. In our case, the callbacks update the node in the OPC-UA server. These callbacks are automatically installed by theua.ServerInterface.ua.extract_profileis a function that inspects the annotations of the class and decorated methods to extract the OPC-UA namespace profile (definition of nodes and RPCs) as well as the mapping between the “Engine” and OPC-UA nodes and methods. Also available is theua.cache_profileclass decorator, which extracts the profile and stores it in the class. If no profile is given to the ServerInterface, it will look for the class’s cached profile. Example:
@ua.cache_profile
class SwitchEngine:
... # see example above
def build_switch_interface(
cfg_dict: dict[str,Any]
) -> ua.IServerInterface[SwitchEngine]:
switch = SwitchEngine()
switch.load_cfg(cfg_dict)
return ua.ServerInterface(
cfg_dict["prefix"], switch # profile cached in switch class
)
Using standard config and interface
The class dvs.server.ServerInterface offers a classmethod from_cfg
which builds a ServerInterface from a config dictionary based on the
FcfSimStdCfg type defined in the configng schema schema/ifw/fcfsim/simlib/fcfsim.schema.yaml.
Following our example:
from dataclasses import dataclass, field
@dataclass
class SwitchServerInterface(dvs.server.ServerInterface):
engine: SwitchEngine = field(default_factory=SwitchEngine)
And for the configng side:
!cfg.include schema/ifw/fcfsim/simlib/fcfsim.schema.yaml:
!cfg.typedef FcfSimSwitchCfg(FcfSimStdCfg):
factory: !cfg.type:string "fcs1.simulators.switch:SwitchServerInterface.from_cfg"
auto_on: !cfg.type:boolean false
Then the config file instance will look like :
!cfg.include schema/mic/fcs1/simulators/switch.schema.yaml
switch1: !cfg.type:FcfSimSwitchCfg
device_name: Switch1
prefix: MAIN.Switch1
namespace: 4
auto_on: false
These configs attributes above are part of the ServerInterface and understood by the
.from_cfg classmethod.
More custom configuration parameters can be added, they will be passed to the
load_cfg method of the engine.
Running the simulator
Using the config file :
> fcfsimServer --cfg config/mic/fcs1/switch/switch.cfg.yaml --port 4840
Or it can be included with other devices using the fcfsim manager, for instance:
# content of config/mic/fcs1.cfg.yaml
!cfg.include schema/ifw/fcfsim/mgr/simmgr.schema.yaml:
fcs1: !cfg.type:FcfSimMgrCfg
devices:
- name: lamp1
cfgfile: 'config/ifw/fcfsim/example/lamp.cfg.yaml'
overrides:
- {name: prefix, value: MAIN.Lamp001}
- name: switch1
cfgfile: config/mic/fcs1/switch/switch.cfg.yaml
overrides:
- {name: prefix, value: MAIN.Switch001}
Then
> fcfsimServer --cfg config/mic/fcs1.cfg.yaml --port 4840
Adding a threaded task: a Runner
One may want to add a thread: a method running a task cyclically (as a PLC does) to handle simulated device behavior. This is easy to do, again with the help of a decorator.
Following the example above:
@ua.cache_profile
class SwitchEngine:
... # above code here
@ua.core.task_method
async def next(self) -> None:
self.stat.counter += 1
...
The task method can have an initialiser called when the server starts. The frequency is set to the server frequency by default, but this can be changed:
@ua.cache_profile
class SwitchEngine:
...
@ua.core.task_method(update_frequency=2.0) # 2.0 Hz
async def next(self) -> None:
self.stat.counter += 1
@next.initialiser
async def initialise(self) -> None:
self.stat.counter = 0
...
The task methods are understood by the ServerInterface to install runners (threads) accordingly. The threads are started when the server starts, with all thread initialisers executed before any cycling tasks.
Note, the function does not have to be async, however it is good practice to have an async method here, for future-proofing.
For further, more complex behavior, or if the programmer does not want to use a decorator,
one can still re-implement the add_to_server method of the Interface class
and declare the Runner threads manually.
Add a State Machine
If one has an scxml state machine, this can be used and easily installed in the engine business logic.
The activity, action and listener business parts of the state machine can be simply declared on the engine with method decorators as well.
The following example assumes that the state machine defines a “TurnOn.Execute” action and a “TurningOn” activity.
The additional requirement asked by the standard ServerInterface is that the engine
must have a method get_event_queue returning an event queue (dvs.sm.EventQueue)
used to share events between the engine business logic and the state machine.
@ua.cache_profile
class SwitchEngine:
def __init__(self):
self.event_queue = dvs.sm.EventQueue()
self.stat = SwitchStat()
def get_event_queue(self) -> dvs.sm.IEventQueue:
return self.event_queue
...
@sm.event_listener_method
def listen_event(self, event: dvs.sm.Event) -> None:
# do something: log, update device data ...
...
@sm.status_listener_method
def listen_state(self,
status: set[dvs.sm.State]
) -> None:
# Listen state change, used to inject state machine new state inside
# the device data
...
@sm.action_method("TurnOn.Execute")
def turn_on_action(
self, handler: dvs.sm.IActionHandler, context: dvs.sm.Context
) -> None:
...
@sm.activity_method("TurningOn")
def turning_on_activity(self, handler: sm.IActivityHandler) -> None:
...
Explanation:
@sm.event_listener_method decorates a method as an event listener for the state machine. Most likely only one is defined. The method must have one single input argument which is a scxml4py Event.
@sm.status_listener_method decorates a method as a status listener. When the state machine state changes, a listener can log or update things (e.g. stat.state, stat.substate) inside the engine data. This method receives a set of scxml4py State.
@sm.action_method decorates the method as an action. The Action id is given to the decorator. The method receives a
dvs.sm.IActionHandler(see below) and a scxml4py Context.@sm.activity_method decorates the method as an activity. The Activity id is given to the decorator. The method receives a
dvs.sm.IActivityHandler.get_event_queue returns the event queue used to share events between the engine and the state machine.
The last step is to provide a scxml file containing the state machine inside the config file schema:
!cfg.include schema/ifw/fcfsim/simlib/fcfsim.schema.yaml:
!cfg.typedef FcfSimSwitchCfg(FcfSimStdCfg):
factory: !cfg.type:string "fcs1.simulators.switch:SwitchServerInterface.from_cfg"
auto_on: !cfg.type:boolean false
state_machine_scxml: !cfg.type:string "config/mic/fcs1/simulator/switch/switch.scxml.xml"
Lastly, one could also want to align defaults in configng and the interface class defaults:
from typing import TextIO
@dataclass
class SwitchServerInterface(dvs.server.ServerInterface):
engine: SwitchEngine = field(default_factory=SwitchEngine)
state_machine_scxml: str | TextIO = "config/mic/fcs1/simulator/switch/switch.scxml.xml"
This allows running the device simulator properly without the need for a config file (e.g. in unit tests).
Subclassing existing simulator
As an example, let us add a “lrVoltage” to the standard lamp device. As usual there isn’t a single way to do it, here is one using class inheritance:
from dataclasses import dataclass, field
from ifw.fcfsim import api as dvs
from ifw.fcfsim.devices.lamp import sim as lamp
@dvs.opcua.cache_profile
@dvs.node.attrconnect # for the new voltage attribute
class MyLampEngine(lamp.LampSimEngine):
voltage: Annotated[float, ua.Ua("stat.lrVoltage")] = 0.0
@dvs.core.task_method
async def next(self) -> None:
await super().next() # Lamp has a next() async task method
self.voltage = self.stat.intensity / 12.34
@dataclass
class MyLampInterface(lamp.LampSimInterface):
engine: MyLampEngine = field(default_factory = MyLampEngine)
A new MyLamp config file should now include the new factory, e.g. factory: "my_module:MyLampInterface.from_cfg".
Composition
Again we can use an example for the description: let us imagine a special device (the equivalent of a TC3 Function Block) containing a Lamp and a Motor.
What is written below concerns only the composition part of the two devices as an illustration; most likely the special device will have additional business logic.
import functools
from dataclasses import dataclass, field
from ifw.fcfsim import api as dvs
from ifw.fcfsim.api import opcua as ua
from ifw.fcfsim.devices.motor import sim as motor
from ifw.fcfsim.devices.lamp import sim as lamp
@dvs.node.attrconnect
class ComboStat:
"""Lamp and motor combined Status"""
state: int = 0
substate: int = 0
@ua.cache_profile
class ComboSimEngine:
motor: Annotated[motor.MotorSimEngine, ua.Ua("fbMotor")]
lamp: Annotated[lamp.LampSimEngine, ua.Ua("fbLamp")]
stat: Annotated[ComboStat, ua.Ua("stat")]
def __init__(self):
self.service = dvs.Service() # contains, log, event queue and signals
self.motor = motor.MotorSimEngine()
self.lamp = lamp.LampSimEngine()
self.stat = ComboStat()
@dvs.core.task_method
async def next(self) -> None:
await self.motor.next()
await self.lamp.next()
self.stat.substate = min(self.motor.stat.substate, self.lamp.stat.substate)
self.stat.state = min(self.motor.stat.state, self.lamp.stat.state)
@next.initialiser
async def initialise(self) -> None:
await self.motor.initialise()
await self.lamp.initialise()
@ua.uamethod("RPC_Reset")
def reset(self) -> Annotated[int,ua.Int16]:
self.motor.reset()
self.lamp.reset()
return 0
@ua.uamethod("RPC_Init")
def init(self) -> Annotated[int,ua.Int16]:
self.motor.init()
self.lamp.init()
return 0
#etc ...
@dataclass
class ComboSimInterface(dvs.server.ServerInterface):
engine: ComboSimEngine = field(default_factory=ComboSimEngine)
def add_to_server(self, server: dvs.opcua.IServerService) -> None:
# super will add nodes and rpcs, including motor's and lamp's because
# they are part of the cached, annotated, profile
super().add_to_server(server)
# We still need to attach motor and lamp in order to have the
# state machine running, but we will **excludes** 'opcua' and 'engine'
server.attach_all(
motor.MotorSimInterface(self.prefix+".fbMotor", self.engine.motor),
lamp.LampSimInterface(self.prefix+".fbLamp", self.engine.lamp),
excludes = {"opcua", "engine"}
)
Notes:
add_to_server this method populates OPC-UA server nodes and RPCs, and adds other potential runners (business thread, state machine, etc …).
excludes Here, for the lamp and motor, we exclude the attachment of “opcua” because it is part of the Combo annotated namespace profile already. We also exclude “engine” (the thread inside motor and lamp) because they are executed within the Combo (by the
.next()task method). However “sm” (state machine) is not excluded, so the lamp and motor state machine runners will be executed by the server.
Note
Another possible implementation could have been:
@ua.cache_profile
class ComboSimEngine:
motor: motor.MotorSimEngine
lamp: lamp.LampSimEngine
stat: Annotated[ComboStat, ua.Ua("stat")]
... # see above
@dvs.core.task_method
async def next(self) -> None:
self.stat.substate = min(self.motor.stat.substate, self.lamp.stat.substate)
self.stat.state = min(self.motor.stat.state, self.lamp.stat.state)
@dataclass
class ComboSimInterface(dvs.server.ServerInterface):
engine: ComboSimEngine = field(default_factory=ComboSimEngine)
def add_to_server(self, server: dvs.opcua.IServerService) -> None:
super().add_to_server(server)
server.attach_all(
motor.MotorSimInterface(self.prefix+".fbMotor", self.engine.motor),
lamp.LampSimInterface(self.prefix+".fbLamp", self.engine.lamp),
)
- The differences are:
motor and lamp namespace profiles are not added to the ComboEngine (no ua.Ua() annotation)
the namespace profile is handled in the ServerInterface add_to_server method
next() tasks of motor and lamp are handled in their own thread.
For another example of composition, one can look at a multi-axis device such as the ADC.