Creating Modules

Take a look at our existing modules to get a feel for what a module can look like.

Start by creating a Python file in your corresponding development folder (see: Setup Dev Env).


Classes and Attributes

Modules have to be derived from the according parent classes. The parent classes are called AbstractInputModule, AbstractVariableModule, AbstractTagModule, AbstractOutputModule, or AbstractProcessorModule. The derived child class has to be named InputModule, VariableModule, TagModule, OutputModule, or ProcessorModule!

Each module needs the following attributes:

__version__: int = 1
"""The auto-generated version of the module."""

class ProcessorModule(AbstractProcessorModule):
    version: int = __version__
    """The version of the module."""
    public: bool = True
    """Is this module public?"""
    description: str = "A short but powerful description of the module."
    """A short description."""
    author: str = "Colin Reiff"
    """The author name."""
    email: str = "colin.reiff@collectu.de"
    """The email address of the author."""
    deprecated: bool = False
    """Is this module deprecated."""
    third_party_requirements: list[str] = []
    """Define your requirements here."""
    can_be_buffer: bool = False  # Only output modules!
    """If True, the child has to implement 'store_buffer_data' and 'get_buffer_data'."""
    field_requirements: list[str] = []  # Only preprocessor and output modules!
    """Data validation requirements for the fields dict."""
    tag_requirements: list[str] = []  # Only preprocessor and output modules!
    """Data validation requirements for the tags dict."""

The available attributes and methods are shown in the following class diagram. Only the _run method is mandatory. The general methods are described below. The methods and features specific to the module type are described in the appropriate sections.

Module Classes

Configuration

The configuration defines parameters, which can be adjusted by the user in order to control the behaviour of the module. A configuration is a dataclass directly located under the module class itself.

The fields of configurations always should have the following attributes: description, dynamic (default: False), and required (default: False).

Additionally, a validation (validate=) can be provided. The following validations are currently available:

OneOf Checks if the value is one of the given possibilities.
Range Checks if the value is in between a min and max value (exclusive).
Regex Performs a regular expression validation with the given regular expression string.
Example
from dataclasses import dataclass, field
from models.validations import Range
from modules.base.inputs.base import AbstractVariableModule, models

class VariableModule(AbstractVariableModule):
    """
    A variable module.
    """

    @dataclass
    class Configuration(models.VariableModule):
        """
        The configuration model of the module.
        """
        key: str = field(
            metadata=dict(description="The key of the constant.",
                          dynamic=True,
                          required=True),
            default=None)  # If the value has to be defined by the user (required=True), provide None as default.
        sleep_time: float = field(
            metadata=dict(description="The interval in seconds until the data is forwarded. "
                                      "Has to be between (exclusive) 0 and 86400 s (24 h).",
                          required=False,
                          dynamic=False,
                          validate=Range(min=0, max=86400)),
            default=1)
Info
Collectu automatically generates the frontend elements from this definition.

The configuration data class should be derived from the corresponding parent class shown below. Be careful not to overwrite the predefined attributes.

Config Classes

Nested Configuration

Nested configurations (here lists as e.g. list[Threshold]) need a special validation. This is achieved by calling NestedListClassValidation on the according field. Furthermore, the __post_init__ method has to be called in the configuration class, in order to set the validated and deserialized field and dynamic variables.

Example
from dataclasses import dataclass, field
from models.validations import validate_module, NestedListClassValidation
from modules.base.inputs.base import AbstractVariableModule, models

@dataclass
class Field:
    """
    The configuration of a single field.
    """
    key: str = field(
        metadata=dict(description="The key of the field.",
                      required=True,
                      dynamic=True),
        default=None)
    value: str | float | int | bool | list = field(
        metadata=dict(description="The (default) value of the field.",
                      required=False,
                      dynamic=True),
        default="Your input")

    def __post_init__(self):
        validate_module(self)


class VariableModule(AbstractVariableModule):
    """
    A variable module.
    """

    @dataclass
    class Configuration(models.VariableModule):
        """
        The configuration model of the module.
        """
        title: str = field(
            metadata=dict(description="The title of the user input element.",
                          required=False),
            default="User Input")
        fields: list[Field] = field(
            metadata=dict(description="The list of fields for this user input module. Example: "
                                      '{"key": "test", "value": ["choice 1", "choice 2"]}',
                          required=False,
                          dynamic=True,
                          validate=NestedListClassValidation(child_class=Field, child_field_name="fields")),
            default_factory=list)

        def __post_init__(self):
            super().__post_init__()
            fields = []
            for field_dict in self.fields:
                fields.append(Field(**field_dict))
            setattr(self, "fields", fields)

Further examples for nested module configurations are:

  • inputs.general.user_input_1
  • processors.dashboard.threshold_1
  • processors.opcua.ads_2_opcua_server_1
  • inputs.control.ads_client_struct_1
  • inputs.control.ads_client_struct_2
  • processors.general.template_var_setter_1

Note: Nested configurations are currently not rendered on the frontend. Users have to provide the nested configuration as json in the according input field.


Third Party Requirements

If third party requirements are used by the module, list them in third_party_requirements. Subsequently, they have to be imported in the import_third_party_requirements method.

Make sure the requirements do not conflict with the global app requirements (listed in requirements.txt). They have to be the same as the global ones. You can check this by executing the test framework (see Testing).

Example
class VariableModule(AbstractVariableModule):
    """
    A variable module.
    """
    third_party_requirements: list[str] = ["redis==4.3.4"]
    """Define your requirements here."""

    ...

    @classmethod
    def import_third_party_requirements(cls) -> bool:
        """
        Import your requirements here and make them globally accessible.
        """
        try:
            global redis
            import redis
            return True
        except Exception:
            raise ImportError("Could not import required packages. Please install '{0}'."
                              .format(' '.join(map(str, cls.third_party_requirements))))
Info
Collectu automatically checks if the third party requirements are installed when a module is used in a configuration. If not, they are automatically installed (if enabled in the settings: AUTO_INSTALL=1).

Get Config Data

The static get_config_data method is used by the front end to retrieve possible configuration options.

Example
@staticmethod
def get_config_data(input_module_instance=None) -> dict[str, Any]:
    """
    Retrieve options for selected configuration parameters of this module.

    :param input_module_instance: If it is a variable or tag module, the input_module_instance is given here,
    if it is required for that module.
    :returns: A dictionary containing the parameters as key and a list of options as value.
    """
    return {"qos": [0, 1, 2],
            "data_type": ['str', 'bool', 'int', 'float', 'list/int', 'list/str', 'list/bool', 'list/float']}

Start and Stop

The start and stop methods are called by the underlying framework. If something goes wrong during the start phase, throw an exception with a meaningful error message. If the user has configured a retry procedure, the start method is called at a defined interval until the method is executed without exceptions.

The modules are started in the following order (and stopped in the reverse order):

  1. Buffer modules
  2. Output modules
  3. Procsesor modules
  4. Input modules
  5. Tag modules
  6. Variable modules

When a module is stopped, the self.active attribute is set to false.


Dynamic Variables

If a configuration variable can be dynamic (dynamic=True), you have to call self._dyn(self.configuration.value) in order to try to replace the marker (e.g. ${module_id.key}) with the actual value in your code.

If the dynamic variable could not be replaced, a DynamicVariableException is raised.

Example
from modules.base.base import DynamicVariableException

try:
    field_key = self._dyn(self.configuration.key)
except DynamicVariableException as e:
    ...