Skip to content

Backend Logic Development

Introduction

In the overall software, the backend is responsible for data storage, data reading and script generation. The plugin backend works the same way. After the plugin is installed into AuboStudio (referred to below as the "main service"), the main service calls the corresponding functions in the plugin when the user performs certain operations. This means the plugin must implement these fixed functions to complete data storage, data reading and script generation. In short, developing a plugin backend is the process of implementing these fixed functions.

File Overview

  • main.ts is used only to export the plugin backend object. Do not modify this file.
  • webscope_cap.impl.ts is the entry file where the plugin interacts with the main service. When the main service loads the plugin, it instantiates the class exported from this file. The class exported from this file must implement the WebscopeCapInterface interface defined in the @aubo/wcaps package to be loaded correctly.
  • The installation and program folders are used to hold the files of each node in the configuration and program modules. You can add or remove nodes in these two folders as needed.

Entry File

As mentioned earlier, the class exported from webscope_cap.impl.ts is instantiated by the main service, and it is a singleton in the main service. You can think of this class as the bridge for data exchange between the plugin and the main service. The two sides must exchange data according to a pre-agreed specification, otherwise neither side can correctly recognize the data sent by the other. @aubo/wcaps is an npm package dedicated to defining data structures and function specifications. In the following introduction you will gradually understand the role of this package.

In the project created by wcaps-builder, webscope_cap.impl.ts already has this basic configuration. Let's look at the full picture of this file first:

typescript
import { WebscopeService } from "@aubo/wcaps/lib/backend/webscope.service";
import { WebscopeCapInterface } from "@aubo/wcaps/lib/backend/wcap.interface";
import { InstallationNodeService } from "@aubo/wcaps/lib/backend/interface/installation/node/installation-node-service";
import { ProgramNodeService } from "@aubo/wcaps/lib/backend/interface/program/node/program-node-service";
import { ProgramNodeServiceImpl } from "./program/node1/program-node-service";
import { InstallationNodeServiceImpl } from "./installation/node1/installation-node-service";

export class MyWebscopeCap implements WebscopeCapInterface {
  webscopeService: WebscopeService;
  readonly _rpcMap: Map<string, Function> = new Map();
  private programNode1 = new ProgramNodeServiceImpl();
  private installNode1 = new InstallationNodeServiceImpl();
  constructor(service: WebscopeService) {
    this.webscopeService = service;
  }
  getProgramNodeServie(): ProgramNodeService[] {
    return [this.programNode1];
  }
  getInstallationNodeServices(): InstallationNodeService[] {
    return [this.installNode1];
  }
  getRpcMap(): Map<string, Function> {
    return this._rpcMap;
  }
}

Next, the role of each line of code in this file is explained item by item:

As mentioned earlier, the main service instantiates this class and passes some common utilities to it during instantiation. These utilities help the plugin obtain data from the main service and call the main service's capabilities, such as getting the current robot arm pose, script running status, printing information to log files, etc.

typescript
import { WebscopeService } from "@aubo/wcaps/lib/backend/webscope.service";
// ……
export class MyWebscopeCap {
  webscopeService: WebscopeService;
  constructor(service: WebscopeService) {
    this.webscopeService = service;
  }
  // ……
}

You only need to declare one constructor parameter to receive the utility. This utility is an implementation of WebscopeService, which is declared in @aubo/wcaps. You can intuitively see which capabilities the main service provides to the plugin by looking at the WebscopeService definition. You can freely distribute this utility to other classes in the plugin and use it anywhere in the plugin.


The point mentioned above ensures that the data passed by the main service to the plugin can be correctly recognized by the plugin. To ensure that the data passed from the plugin to the main service can be correctly recognized by the main service, the class exported by the plugin must implement the WebscopeCapInterface interface under the @aubo/wcaps package.

typescript
import { WebscopeCapInterface } from "@aubo/wcaps/lib/backend/wcap.interface";
export class MyWebscopeCap implements WebscopeCapInterface {}

After implementing the corresponding functions, the main service can correctly obtain the data and complete the plugin loading and subsequent logic interaction.

typescript
import { InstallationNodeService } from "@aubo/wcaps/lib/backend/interface/installation/node/installation-node-service";
import { ProgramNodeService } from "@aubo/wcaps/lib/backend/interface/program/node/program-node-service";
import { WebscopeCapInterface } from "@aubo/wcaps/lib/backend/wcap.interface";
// ……

export class MyWebscopeCap implements WebscopeCapInterface {
  // ……
  getRpcMap(): Map<string, Function> {
    return new Map();
  }
  getProgramNodeServie(): ProgramNodeService[] {
    return [];
  }
  getInstallationNodeServices(): InstallationNodeService[] {
    return [];
  }
}

By now you should have some understanding of the @aubo/wcaps package, mainly covering the following two roles:

  • Define which utilities the main service provides to the plugin and what capabilities these utilities have
  • Define which functions the plugin needs to implement and what data these functions return

The utility WebscopeService that the main service passes to the plugin currently declares the following capabilities:

CategoryAccess MethodPurpose
Robot interactiongetRobotProxy()Get robot arm data and control robot arm movement via aubo_sdk
LogginggetLogger()Write content to log files
Communicate with AppgetWebsocket()Actively push data to the frontend
System datagetSystemApi()getScopeSetting()getDataPool()Get device information, user settings and other shared data
Configuration datagetTcpModel()getIoModel()getFeatureModel()getPayloadModel()Get configuration module TCP, IO, coordinate system, payload and other data
Program datagetWaypointModel()getVariableModel()getTimerModel()getLoopModel()Get program module waypoints, variables, timers, loops and other data

The functions the plugin needs to provide to the main service and their purposes:

Function NamePurpose
getRpcMap()Register the interfaces to be provided to the plugin frontend into the main service through this function, to implement data interaction between the plugin frontend and backend
getInstallationNodeServices()Expose the configuration nodes to be registered into the main service through this function; the main service treats these nodes as nodes in the configuration module
getProgramNodeServie()Expose the program nodes to be registered into the main service through this function; the main service treats these nodes as nodes in the program module

Configuration Node

A configuration node is instantiated only once in the main service and is often used to store some global parameters. You can think of a configuration node as preparing for the program that is about to run; subsequent physical operations run based on the parameters in the configuration node, so the script generated by the configuration node is placed above the program node. A configuration node consists of three classes: service, config and node. service is used to instantiate and export the latter two, config stores some basic information, and node is the core logic class. Let's look at the full picture of a node class first:

typescript
import { ScriptWriter, DataModel } from "@aubo/wcaps/lib/backend/domain";
import { InstallationNode } from "@aubo/wcaps/lib/backend/interface/installation/node/installation-node";
export class InstallationNodeImpl implements InstallationNode {
  dataModel: DataModel | undefined;
  generateScript(scriptWriter: ScriptWriter): void {
    scriptWriter.appendLine("-------- Fill in the Lua script --------");
  }
  setModel(dataModel: DataModel) {
    this.dataModel = dataModel;
  }
}

There are two core interactions between the main service and the plugin in a configuration node: setModel() and generateScript(). The main service calls these two functions when the user performs open configuration file and run program. When the user opens a configuration file, the main service distributes the data belonging to this node in the configuration file to it. The distributed data structure is DataModel, which internally contains a Map for storing data. When the user triggers save configuration file, the main service writes the data in this DataModel into the configuration file. Data that needs to be persisted can be stored in this DataModel.

generateScript(), as its name suggests, is the function used to generate scripts. The input parameter ScriptWriter of this function is also defined in the @aubo/wcaps package. You can use this utility to conveniently generate scripts.

Program Node

A program node also consists of three classes: service, config and node. Compared with a configuration node, a program node is much more complex. The detailed configuration in the config class is as follows:

Property namePurposeData TypeRequiredDefault
idRepresents node identity, must be consistent with the plugin frontend configuration, cannot duplicate built-in node names.stringYes
beforeNodesConfigure the node types allowed as direct predecessors of this node, used to restrict sibling node orderArray<ProgramNodeId>Yes
parentNodesConfigure the node types allowed as direct parents of this node, used to restrict where this node can be placedArray<ProgramNodeId>Yes
childNodesConfigure the node types allowed as direct children of this node, used to restrict which node types can be inserted under this nodeArray<ProgramNodeId>Yes
ancestorsNodesConfigure the node types that must exist in the ancestor chain, used to restrict this node to specific contextsArray<ProgramNodeId>Yes
isChildrenAllowedIndicates whether child nodes are required. If true, an EmptyNode is automatically added as a placeholder when emptybooleanNofalse
isInsertDisabledIndicates whether inserting child nodes under this node is disabled. If true, no nodes can be inserted under this nodebooleanNofalse

Configuring this file correctly implements the permission control of the program node.


The service class of a program node is the entry point for the main service to manage this type of program node. As mentioned earlier, a program node's service is instantiated only once, while each node created by the user on the program tree produces a new node instance. Creating these node instances, returning the node's static configuration, restoring saved nodes and cloning nodes are all responsibilities of the service class.

The project created by wcaps-builder already contains a basic implementation:

typescript
import { DataModel } from "@aubo/wcaps/lib/backend/domain";
import { ProgramNode } from "@aubo/wcaps/lib/backend/interface/program/node/program-node";
import { ProgramNodeConfig } from "@aubo/wcaps/lib/backend/interface/program/node/program-node-config";
import {
  CreateNodeRes,
  ProgramNodeService,
} from "@aubo/wcaps/lib/backend/interface/program/node/program-node-service";
import {
  ProgramModel,
  TreeNodeModel,
} from "@aubo/wcaps/lib/backend/interface/program/program-model";
import TreeModel from "tree-model";
import { ProgramNodeImpl } from "./program-node";
import { ProgramNodeConfigImpl } from "./program-node-config";

export class ProgramNodeServiceImpl implements ProgramNodeService {
  config: ProgramNodeConfigImpl = new ProgramNodeConfigImpl();
  programNodeModel: ProgramModel;

  setProgramModel(programNodeModel: ProgramModel): void {
    this.programNodeModel = programNodeModel;
  }

  getProgramNodeConfig(): ProgramNodeConfig {
    return this.config;
  }

  createNode(parentId: string): CreateNodeRes {
    return {
      nodeId: this.config.id,
      node: new ProgramNodeImpl(this.programNodeModel),
      children: [],
    };
  }

  loadFromXml(dataModel: DataModel): ProgramNode {
    const node = new ProgramNodeImpl(this.programNodeModel);
    node.setDataModel(dataModel);
    return node;
  }

  cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
    return new ProgramNodeImpl(this.programNodeModel).cloneNode(node);
  }
}

The service class must implement the ProgramNodeService interface. When the main service loads the plugin, it obtains this object through getProgramNodeServie() of the entry class, and then registers it in the program node factory according to the id returned by getProgramNodeConfig(). Afterwards, when the main service needs to create, load or clone this type of node, it can find the corresponding service through this id.

You need to distinguish the instance counts of the three objects service, config and node:

ObjectInstance CountStored Content
serviceOne per program node typeNode lifecycle management logic, and the ProgramModel provided by the main service
configOne per program node typeStatic configuration such as node type ID, structural constraints and operation permissions
nodeOne for each node created on the program treeIndependent business data, page configuration and the final Lua script to generate

Therefore, do not store the mutable data of a specific program node in service or config. Otherwise, nodes of the same type on the program tree will share this data, and modifying one node may affect other nodes. The parameters of each node should be stored in the corresponding node instance and its DataModel.


When the main service registers a program node service, it calls setProgramModel() to pass in the current program model:

typescript
programNodeModel: ProgramModel;

setProgramModel(programNodeModel: ProgramModel): void {
  this.programNodeModel = programNodeModel;
}

ProgramModel is the entry point for the plugin to access the current program context. It can be used to obtain the program tree and the service of other nodes. All node instances created by service also need to use the same ProgramModel, so you should save it first, then pass it to the node constructor in createNode(), loadFromXml() and cloneNode().

This function is called by the main service; the plugin does not need to call it manually in the entry class. Note that you should not create node instances that depend on ProgramModel before setProgramModel() is called.


getProgramNodeConfig() is used to provide the static configuration of this type of node to the main service:

typescript
config: ProgramNodeConfigImpl = new ProgramNodeConfigImpl();

getProgramNodeConfig(): ProgramNodeConfig {
  return this.config;
}

The main service uses the configuration returned here to load this type of node. It should always return the same config object. config.id must also be consistent with the nodeId returned by createNode() and with the ID used when the plugin frontend registers the program node page.


When the user adds a node to the program tree from the page, the main service calls createNode():

typescript
createNode(parentId: string): CreateNodeRes {
  return {
    nodeId: this.config.id,
    node: new ProgramNodeImpl(this.programNodeModel),
    children: [],
  };
}

The input parameter parentId is the instance ID of the parent node that will host the new node. It is not the type ID of the parent node, nor is it the id in the current node's configuration. For an ordinary node that does not depend on its parent node, you can ignore it as in the template; if the node's default data needs to be initialized based on the parent node, you can query the parent node information through ProgramModel.

The return value CreateNodeRes contains three fields:

FieldPurpose
nodeIdThe new node's type ID, should return config.id
nodeThe newly created node instance; a new object must be created on every call
childrenThe default child nodes to be created together when creating the node; ordinary nodes return an empty array, and the main service recursively processes non-empty child nodes

Here you do not need to generate a program tree instance UUID for the node, nor proactively create a DataModel. The main service completes these two tasks when adding the returned node to the program tree, and calls node.setId() and node.setDataModel(). nodeId represents the node type and is shared by nodes of the same type; the instance UUID is used to distinguish each specific node on the program tree.

If creating a node must come with a fixed default child structure, you can continue to return CreateNodeRes in children:

typescript
createNode(parentId: string): CreateNodeRes {
  const childNode = new ChildProgramNodeImpl(this.programNodeModel);

  return {
    nodeId: this.config.id,
    node: new ProgramNodeImpl(this.programNodeModel),
    children: [
      {
        nodeId: "my-plugin-child",
        node: childNode,
        children: [],
      },
    ],
  };
}

children is suitable for describing the fixed structure that must be generated when a node is created. If you merely want to display a placeholder node when the node is empty, you do not need to manually return an EmptyNode. After configuring isChildrenAllowed = true in config, the main service adds it automatically.


When the user opens a saved program file, the main service reads the data belonging to this node and calls loadFromXml() to restore a node instance:

typescript
loadFromXml(dataModel: DataModel): ProgramNode {
  const node = new ProgramNodeImpl(this.programNodeModel);
  node.setDataModel(dataModel);
  return node;
}

Here you need to complete two things:

  1. Create a new node instance;
  2. Call setDataModel(), handing the data restored from the program file by the main service to it.

Do not return a node object saved in service in advance here. A program file may contain multiple nodes of the same type, and every call should return a new instance that is isolated from the others. The node instance ID, title and its hierarchical relationship in the program tree are restored by the main service in the subsequent loading flow. The core responsibility of loadFromXml() is to restore the business data inside the node.

The difference between createNode() and loadFromXml() is that the former creates a new node using default values, while the latter creates an old node restored with persisted data.


User copy, paste operations, and some undo/redo operations require cloning an existing node through cloneNode():

typescript
cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
  return new ProgramNodeImpl(this.programNodeModel).cloneNode(node);
}

TreeNodeModel contains not only the current node, but also program tree information such as the node's type, suppression state, breakpoint state and child nodes. Therefore, the cloning work is ultimately completed by cloneNode() in the node class, while service is responsible for creating a new target instance and passing the original node data to it.

Here you must also new a new ProgramNodeImpl. Do not directly return the original node, and do not make only a shallow copy of the object, otherwise the cloned node may share DataModel or child node data with the original node. For nodes that are allowed to have child nodes, node.cloneNode() also needs to recursively call the service.cloneNode() of each child node type. This part will be introduced in detail in the node class.

At this point, the service class has completed the four management tasks of the same type of program nodes: providing static configuration to the main service, receiving the program context, creating new nodes, and restoring and cloning existing nodes. Next, you need to implement the real data update, persistence and script generation logic of each node instance in the node class.


The node class is the core logic class of a program node. Each time a node is created on the program tree, the main service creates a new node instance through service. This instance is responsible for saving the current node's own parameters, receiving frontend modifications, determining whether the configuration is complete, generating Lua scripts, and providing the data that needs to be persisted when saving the program.

For ease of understanding, the complete structure of the node class is shown below using a node with a message parameter that outputs a message to the robot log at runtime as an example:

typescript
import { DataModel, ScriptWriter } from "@aubo/wcaps/lib/backend/domain";
import { ProgramNode } from "@aubo/wcaps/lib/backend/interface/program/node/program-node";
import {
  ProgramModel,
  ProgramNodeData,
  ProgramNodeId,
  ProgramTreeNodeData,
  TreeNodeModel,
} from "@aubo/wcaps/lib/backend/interface/program/program-model";
import TreeModel from "tree-model";

export class ProgramNodeImpl implements ProgramNode {
  private id: string;
  private dataModel: DataModel;
  private programModel: ProgramModel;

  private readonly messageKey = "message";
  private message = "Hello AUBO";

  constructor(programModel: ProgramModel) {
    this.programModel = programModel;
  }

  setId(id: string): void {
    this.id = id;
  }

  update(config: ProgramNodeData): boolean {
    for (const key in config) {
      if (key === this.messageKey) {
        this.message = String(config[key]);
      }
    }
    return true;
  }

  dropNode(): void {}

  toJSON(): ProgramTreeNodeData {
    return {
      message: {
        value: this.message,
        hasDefined: this.message.trim().length > 0,
      },
    };
  }

  getTitle(): string {
    return this.message ? `Message: ${this.message}` : "Message";
  }

  hasDefined(): boolean {
    return this.message.trim().length > 0;
  }

  generateScript(scriptWriter: ScriptWriter): void {
    scriptWriter.appendLine(`textmsg(${JSON.stringify(this.message)})`);
  }

  setDataModel(dataModel: DataModel): void {
    this.dataModel = dataModel;
    this.message = dataModel.get(this.messageKey, this.message);
  }

  getDataModel(): DataModel {
    this.dataModel.set(this.messageKey, this.message, "String");
    return this.dataModel;
  }

  cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
    const treeModelInstance = new TreeModel();
    const parseTree: TreeModel.Node<TreeNodeModel> = treeModelInstance.parse({});
    const { children, programNode } = node;

    const nodeData = programNode.getDataModel();
    this.setDataModel(nodeData);

    const currentNode = this.programModel
      .getProgramTree()
      .addChild(parseTree, node.nodeId, this, 0);

    currentNode.model.disabled = node.disabled;
    currentNode.model.isBreakpoint = node.isBreakpoint;
    currentNode.model.suppressedComment = node.suppressedComment;

    if (children && children.length > 0) {
      for (const child of children) {
        const childTree = this.programModel
          .getProgramNodeFactory()
          .getProgramNodeServiceById(<ProgramNodeId>child.nodeId)
          .cloneNode(child);
        currentNode.addChild(childTree);
      }
    }

    return currentNode;
  }
}

A node instance mainly contains three types of data:

DataSourcePurpose
idInjected by the main service via setId()Identifies a specific node instance on the program tree
programModelPassed by service when creating the nodeAccess the current program tree and the service of other program nodes
message and other business fieldsDefault values, frontend modifications or program filesDetermine page display content, node completeness and the final Lua script
dataModelInjected by the main service via setDataModel()Stores data that needs to be persisted with the program file

Business fields are the data used directly during node runtime, and DataModel is the persistence carrier of these business fields. During development you need to ensure the two can convert to each other:

text
Open program file: DataModel → setDataModel() → business fields
Save program file: business fields → getDataModel() → DataModel

The constructor only receives the program context and sets reasonable default values for business fields:

typescript
private programModel: ProgramModel;
private message = "Hello AUBO";

constructor(programModel: ProgramModel) {
  this.programModel = programModel;
}

Here you usually do not need to create a DataModel or generate a node ID. When the main service adds the node to the program tree, it creates a DataModel and generates an instance UUID for it, then calls setId() and setDataModel() in turn.

programModel can be used to access the program tree and the service of other nodes. An ordinary independent node may only use it when cloning; when you need to query the parent node, find other nodes, or manage child nodes, you can also access the current program context through it.


setId() receives the node instance ID generated by the main service:

typescript
private id: string;

setId(id: string): void {
  this.id = id;
}

The id here is the UUID of a specific node on the program tree, which is different from the meaning of config.id:

IDDefined by pluginShared by same-type nodesPurpose
config.idYesYesIdentify node type, register service
node.idNoNoIdentify a specific node instance on the program tree

The plugin only needs to save this value and should not regenerate or overwrite it. When a node is cloned or restored via undo/redo, the main service may also call setId() again to update the instance ID.


After the user modifies the node parameters in the frontend, the main service passes the modified key-value pairs to update():

typescript
update(config: ProgramNodeData): boolean {
  for (const key in config) {
    if (key === this.messageKey) {
      this.message = String(config[key]);
    }
  }
  return true;
}

ProgramNodeData is an ordinary key-value object, for example:

typescript
{
  message: "Start picking"
}

In update() you should complete type conversion, validity checks and business field updates based on the field name. The field name needs to be consistent with the data submitted by the frontend. When a node has multiple configuration items, you can continue to add checks:

typescript
if (key === this.messageKey) {
  this.message = String(config[key]);
} else if (key === this.countKey) {
  this.count = Number(config[key]);
} else if (key === this.enabledKey) {
  this.enabled = config[key] === true || config[key] === "true";
}

Do not directly overwrite the whole frontend object onto the node instance. Frontend data needs type conversion and validation, and unrecognized fields should be ignored. In particular, do not use Boolean("false") to convert a string directly, because its result is still true. update() returning true means the update succeeded; when the input cannot be accepted, you can return false. After the update, the main service calls hasDefined(), getTitle() and toJSON() again and synchronizes the node's new state to the frontend.


toJSON() describes the data the node currently provides to the frontend and whether each item is complete:

typescript
toJSON(): ProgramTreeNodeData {
  return {
    message: {
      value: this.message,
      hasDefined: this.message.trim().length > 0,
    },
  };
}

Each configuration item contains:

FieldPurpose
valueThe current value; the frontend can use it to restore the node page
hasDefinedWhether the current configuration item is valid and complete

toJSON() returns the node data snapshot used when the main service and frontend interact; it is not the persistence format of the program file. The data actually written to the program file comes from getDataModel(). To avoid inconsistency between the page state and the persisted state, the same parameters in toJSON(), update() and DataModel should use consistent field names.


getTitle() returns the node title displayed in the program tree:

typescript
getTitle(): string {
  return this.message ? `Message: ${this.message}` : "Message";
}

The title can be fixed text or dynamically generated based on the current business fields. The main service re-fetches the title after creating a node, loading a program, or when the node data changes, so you do not need to directly modify the title on the program tree in update().

The title should be as short as possible and allow users to identify the current configuration without opening the node page. For example:

text
Message: Start picking
Wait: 2s
Set output: DO_01 = High

hasDefined() determines whether the current node has completed the necessary configuration:

typescript
hasDefined(): boolean {
  return this.message.trim().length > 0;
}

When it returns false, the main service marks the node as incompletely defined; a parent node on the program tree may also become undefined because its child nodes are undefined. When generating scripts, the main service only calls nodes that are not suppressed and have been fully defined.

Therefore, this function should check all parameters necessary for generating scripts, and should not always return true. For example, if a node must configure both the output port and the output state, you can write:

typescript
hasDefined(): boolean {
  return !!this.output && this.outputValue !== undefined;
}

The hasDefined of each field in toJSON() describes the state of a single configuration item, while node.hasDefined() gives the final state of the whole node.


generateScript() is responsible for converting the current node's business parameters into Lua scripts:

typescript
generateScript(scriptWriter: ScriptWriter): void {
  scriptWriter.appendLine(`textmsg(${JSON.stringify(this.message)})`);
}

Common ScriptWriter functions include:

FunctionPurpose
appendLine(script)Append a line of script using the current indentation
appendRaw(script)Append a segment of script as-is
note(comment)Write a comment
writeChildren()Recursively generate the scripts of all child nodes of the current node at the current position
increaseIndent()Increase indentation
decreaseIndent()Decrease indentation
ifCondition()end()Generate conditional structures
sleep(seconds)Generate a wait instruction

If the current node is a container with child nodes, you must call writeChildren() at the appropriate position, otherwise child nodes exist in the program tree but their scripts will not be generated. For example, a container that only organizes child nodes can be written as:

typescript
generateScript(scriptWriter: ScriptWriter): void {
  scriptWriter.note("my container start");
  scriptWriter.writeChildren();
  scriptWriter.note("my container end");
}

Do not concatenate user input into a Lua string without processing, otherwise characters such as quotes and newlines may break the script structure. The example uses JSON.stringify(this.message) to escape the string; for numbers and booleans, you should also complete validity checks before generating the script.


setDataModel() and getDataModel() together complete the reading and saving of node data:

typescript
setDataModel(dataModel: DataModel): void {
  this.dataModel = dataModel;
  this.message = dataModel.get(this.messageKey, this.message);
}

getDataModel(): DataModel {
  this.dataModel.set(this.messageKey, this.message, "String");
  return this.dataModel;
}

The data direction of setDataModel() is restoring business fields from DataModel. When calling get(), the second parameter is the default value: when the program file does not have the corresponding field, the current default value is kept. This allows subsequent versions to add new fields while remaining compatible with old program files.

The data direction of getDataModel() is writing business fields back to DataModel. The main service calls this function before saving the program file, and then serializes the returned data into the program file.

The third parameter of DataModel.set() indicates the type tag used when writing data to XML. Common values include:

TypeScript dataType tag example
string"String"
boolean"Bool"
integer"Int32" or "Int64"
decimal"Float32" or "Float64"
Array<string>"VectorString"
Array<number>"VectorDouble"
Expression, IO, waypoint and other objects"Expression""Io""Waypoint"

When saving and reading the same field, the key name and data type must remain consistent. Do not save the same key as a string and later read it as a number, otherwise DataModel may not correctly restore the original value.


When a node is removed from the program tree, the main service calls dropNode():

typescript
dropNode(): void {}

An ordinary node can keep it empty when it does not occupy external resources. If the node has created variables, timers, subscriptions, event listeners or other resources that need explicit release, you should remove references or clean up resources here. For example:

typescript
dropNode(): void {
  this.unsubscribe?.();
}

For a node with child nodes, the main service calls dropNode() on every node in turn when deleting the whole subtree; the plugin does not need to repeatedly clean up ordinary child nodes in the parent node.


cloneNode() is used to clone the current node's business data, program tree state and all child nodes:

typescript
cloneNode(node: TreeNodeModel): TreeModel.Node<TreeNodeModel> {
  const treeModelInstance = new TreeModel();
  const parseTree: TreeModel.Node<TreeNodeModel> = treeModelInstance.parse({});
  const { children, programNode } = node;

  const nodeData = programNode.getDataModel();
  this.setDataModel(nodeData);

  const currentNode = this.programModel
    .getProgramTree()
    .addChild(parseTree, node.nodeId, this, 0);

  currentNode.model.disabled = node.disabled;
  currentNode.model.isBreakpoint = node.isBreakpoint;
  currentNode.model.suppressedComment = node.suppressedComment;

  if (children && children.length > 0) {
    for (const child of children) {
      const childTree = this.programModel
        .getProgramNodeFactory()
        .getProgramNodeServiceById(<ProgramNodeId>child.nodeId)
        .cloneNode(child);
      currentNode.addChild(childTree);
    }
  }

  return currentNode;
}

The flow of this code is as follows:

  1. Get the latest business data from the original node's getDataModel();
  2. Restore this data through the new instance's setDataModel();
  3. Create a temporary tree and let the main service generate a new instance ID and independent DataModel for the cloned node;
  4. Restore the original node's suppression state, breakpoint state and suppression remark;
  5. If child nodes exist, find the corresponding service by each child node's type ID and complete the cloning recursively.

Do not directly return the original TreeNodeModel, and do not let the new and old nodes share the same DataModel for a long time, otherwise modifying either node after cloning may affect the other. In the template, the call order first reads the original data, and then the program tree establishes an independent data model for the new node.

If the node registers variables, waypoints and other data with global identity, copying only the DataModel may not be enough; you also need to create new resources or re-establish references during cloning. The specific behavior should be determined based on the business.


At this point, a program node backend has a complete data lifecycle:

text
Create node → set default values → frontend calls update() to modify parameters
         → toJSON()/getTitle()/hasDefined() refresh page state
         → getDataModel() save program file
         → setDataModel() open program file
         → generateScript() generate Lua script
         → cloneNode()/dropNode() handle clone and delete

After development, at least the following scenarios should be verified:

  • Create two nodes of the same type consecutively; modifying one does not affect the other;
  • After saving and reopening the program, the node parameters and title can be correctly restored;
  • When required parameters are empty, hasDefined() returns false; after configuration is complete, it returns true;
  • After cloning a node, the new and old nodes have the same data but can be modified separately;
  • A container node can correctly generate the scripts of all child nodes at the right position;
  • When user input contains characters such as quotes and newlines, the generated Lua script remains valid;
  • After deleting a node, the external resources created by the node have been correctly released.