WitEngine: Building Modular Controllers for Script Automation
Dmitry
Posted on October 24, 2024
Automation is at the heart of modern software and hardware systems. Whether you’re managing complex hardware interactions or streamlining repetitive tasks, having a flexible and modular approach to scripting can save both time and effort. That’s where WitEngine comes in.
I created WitEngine to address the challenges I faced in projects that required seamless control of multiple devices and systems. The ability to quickly modify scripts and add new functionalities without having to overhaul the entire setup is a game-changer, especially in environments where time and precision are critical.
For a deeper dive into the features and capabilities of WitEngine, check out the WitEngine project page. Here, I’ll guide you through getting started with WitEngine, including how to create a controller, run processes, and automate tasks.
Table of Contents
- What is WitEngine?
- Getting Started with WitEngine
- The Script Structure
- Creating Controllers in WitEngine
- Defining the Controller Module
- Conclusion
What is WitEngine?
At its core, WitEngine is a modular API designed to help you build flexible interpreters for simple scripts. It allows you to manage complex tasks by breaking them down into independent modules called controllers. These controllers define specific variables, functions, and processes, which the main interpreter (or host) loads from a designated folder. This modular approach makes WitEngine highly extensible, as you can easily add new controllers without modifying the entire system.
For example, in a hardware setup like a photobox, you could have one controller for handling the rotation of a table, another for changing background colors, and yet another for taking snapshots with cameras. Each of these controllers can be developed and tested independently, then combined to execute a seamless process.
What makes WitEngine powerful is that it allows you to automate interactions between hardware devices or systems, while keeping the logic clean and easy to maintain. Scripts can freely reference variables and functions from multiple controllers, giving you the flexibility to define workflows as needed.
Getting Started with WitEngine
Getting started with WitEngine in your project is straightforward. (You can find the examples and a pre-configured demo project here: GitHub repository).
Here’s how to begin:
1) Set the Controllers Folder
WitEngine needs a folder where the controllers will be located. By default, the system will look for controllers in a folder named @Controllers
, located in the same directory as the running .exe
file. However, you can specify any other path if needed.
2) Reload Controllers
To load controllers from the designated folder, call the following function:
WitEngine.Instance.Reload(null, null, null);
The Reload
function takes three parameters:
-
Local resource access interface – for now, set it to
null
. -
Logger interface – this can also be
null
. -
Path to the controllers folder –
null
will use the default path. After calling this function, the controllers located in the selected folder will be loaded into the system.
3) Add Event Handlers
Next, you’ll need to add handlers to respond to different stages of WitEngine’s processing:
WitEngine.Instance.ProcessingProgressChanged += OnProcessingProgressChanged;
WitEngine.Instance.ProcessingCompleted += OnProcessingCompleted;
WitEngine.Instance.ProcessingPaused += OnProcessingPaused;
WitEngine.Instance.ProcessingReturnValue += OnProcessingReturnValue;
WitEngine.Instance.ProcessingStarted += OnProcessingStarted;
4) Running Your First Script
Now, you’re ready to run your first script. Here’s how:
Load the Script
Load the script text into a variable, for example:
string jobString = File.ReadAllText(@"C:\Jobs\testJob.job");
Deserialize the Script
Parse and compile the script by calling the Deserialize() extension function:
WitJob job = jobString.Deserialize();
If there’s a compilation error, an exception will be thrown, indicating which specific block failed to parse.
Execute the Script
Run the script by passing the job object to the ProcessAsync() function:
WitEngine.Instance.ProcessAsync(job);
The Script Structure
Here’s an example of a minimal WitEngine script:
~This is a minimal test job~
Job:TestJob()
{
Int:value= 2;
Return(value);
}
Let’s walk through the lines of the script:
Comments
The first line (~This is a minimal test job~
) is a comment, which is ignored by the parser. Comments can be placed anywhere in the script, and they behave as expected from typical comments in other languages.
Top-level Function Definition
The second line defines the top-level function, which serves as the entry point:
- Job: A keyword indicating the type (top-level function).
- :: Separates the type from the name.
- TestJob: The name of the function (can be any name).
-
(): This script doesn’t accept parameters, but you can pass parameters when calling
ProcessAsync()
if needed:
public void ProcessAsync(WitJob job, params object[] parameters)
Block Definition
The {}
braces define the block of logic.
Variable Definition and Assignment
The line Int:value= 2;
defines a variable and assigns it a value:
- Int: Variable type.
- :: Separator between the type and the name.
- value: Variable name.
- = 2: Assigns the value 2.
- ;: Marks the end of the statement.
Return Statement
The Return(value);
statement sends the value outside the script:
-
Return: Sends a value via the
ProcessingReturnValue
callback. Unlike typicalreturn
statements (e.g., in C#), this does not terminate the script’s execution. It can be called multiple times during script execution, and each result can be caught via theProcessingReturnValue
callback. -
(): Encapsulates the values being returned. In this case, only
value
is returned, but multiple values can be passed, separated by commas. - ;: Ends the statement.
End of Script
The closing brace }
indicates the end of the logic block and the script.
When this script runs, the ProcessingReturnValue callback will receive the value 2 as an integer:
private void OnProcessingReturnValue(object[] value)
{
// Handle the returned value
}
Creating Controllers in WitEngine
Each controller in **WitEngine **can define variables (Variables), functions (Activities), the logic for parsing and serializing these elements, as well as the core business logic that will be accessible via the defined variables and functions.
Defining Variables
Let’s assume we have a class that describes a color using three components:
public class WitColor : ModelBase
{
#region Constructors
public WitColor(int red, int green, int blue)
{
Red = red;
Green = green;
Blue = blue;
}
#endregion
#region Functions
public override string ToString()
{
return $"{Red}, {Green}, {Blue}";
}
#endregion
#region ModelBase
public override bool Is(ModelBase modelBase, double tolerance = DEFAULT_TOLERANCE)
{
if (!(modelBase is WitColor color))
return false;
return Red.Is(color.Red) &&
Green.Is(color.Green) &&
Blue.Is(color.Blue);
}
public override ModelBase Clone()
{
return new WitColor(Red, Green, Blue);
}
#endregion
#region Properties
public int Red { get; }
public int Green { get; }
public int Blue { get; }
#endregion
}
Now, to use such objects in a WitEngine script, we need to define a corresponding variable. Here’s how to add that variable definition:
[Variable("Color")]
public class WitVariableColor : WitVariable<WitColor>
{
public WitVariableColor(string name) : base(name) { }
#region Functions
public override string ToString()
{
var value = Value?.ToString() ?? "NULL";
return $"{Name} = {value}";
}
#endregion
}
Two important points to note:
- The variable class implements the abstract generic class
WitVariable
, with the object type (WitColor
) as the parameter. - The class is marked with the
Variable
attribute, where the parameter specifies the word to represent the variable in the script. In this case, it’sColor
.
Creating a Variable Adapter
Next, we need to create an adapter that helps in parsing the script and interacting with the variable:
public class WitVariableAdapterColor : WitVariableAdapter<WitVariableColor>
{
public WitVariableAdapterColor() : base(ServiceLocator.Get.ControllerManager) { }
protected override WitVariableColor DeserializeVariable(string name, string valueStr, IWitJob job)
{
if (string.IsNullOrEmpty(valueStr) || valueStr == "NULL")
return new WitVariableColor(name);
Manager.Deserialize($"{name}={valueStr};", job);
return new WitVariableColor(name);
}
protected override string SerializeVariableValue(WitVariableColor variable)
{
return "NULL";
}
protected override WitVariableColor Clone(WitVariableColor variable)
{
return new WitVariableColor(variable.Name)
{
Value = variable.Value == null
? null
: new WitColor(variable.Value.Red, variable.Value.Green, variable.Value.Blue)
};
}
}
This class implements the abstract generic class WitVariableAdapter
, with our defined variable type as the parameter.
The key functions here are:
-
DeserializeVariable
: Parses the script to create a variable. -
SerializeVariableValue
: Handles serialization of the variable.
DeserializeVariable
Let’s break down the DeserializeVariable
function:
protected override WitVariableColor DeserializeVariable(string name, string valueStr, IWitJob job)
{
if (string.IsNullOrEmpty(valueStr) || valueStr == "NULL")
return new WitVariableColor(name);
Manager.Deserialize($"{name}={valueStr};", job);
return new WitVariableColor(name);
}
- name: The name of the variable.
- valueStr: The expression after the “=” symbol that leads to the creation of this variable.
- job: The current script’s compilation tree.
In simpler cases, such as an int
, valueStr
would contain the direct value of the variable. For example:
Int:val=2;
In this case, the name
parameter would receive “val”, and valueStr
would be “2”. But more complex cases, such as function calls, require deeper parsing:
Int:val=DoSomeAction();
This deeper parsing is done by:
Manager.Deserialize($"{name}={valueStr};", job);
SerializeVariableValue
Unlike DeserializeVariable
, this function handles only final values, meaning it provides default values like NULL
for complex objects such as WitColor
.
Now, let’s register the adapter. In your controller module, you’ll inject IWitControllerManager
and register the adapter:
private void RegisterAdapters(IWitControllerManager controllers)
{
controllers.RegisterVariable(new WitVariableAdapterColor());
}
Defining Functions
Now that we have the variable, we need a function to create it. We’ll define a simple “constructor”-like function to create a WitColor
object in the script.
First, declare the function definition:
[Activity("WitColor")]
public class WitActivityColor : WitActivity
{
public WitActivityColor(string color, int red, int green, int blue)
{
Color = color;
Red = red;
Green = green;
Blue = blue;
}
public string Color { get; }
public int Red { get; }
public int Green { get; }
public int Blue { get; }
}
This class implements WitActivity
and stores everything necessary for the function to operate—in this case, the variable name (Color
) and the three components of color (Red
, Green
, Blue
).
Like with variables, the function name in the script is defined by the attribute:
[Activity("WitColor")]
Thus, a WitColor
object can be created in the script like this:
Color:val = WitColor(1, 2, 3);
Creating a Function Adapter
Next, we need an adapter to guide WitEngine on how to process this function:
public class WitActivityAdapterColor : WitActivityAdapterReturn<WitActivityColor>
{
public WitActivityAdapterColor() :
base(ServiceLocator.Get.ProcessingManager, ServiceLocator.Get.Logger, ServiceLocator.Get.Resources) { }
protected override void ProcessInner(WitActivityColor action, WitVariableCollection pool, ref string message)
{
pool[action.Color].Value = new WitColor(action.Red, action.Green, action.Blue);
}
protected override string[] SerializeParameters(WitActivityColor activity)
{
return new[]
{
$"{activity.Color}",
$"{activity.Red}",
$"{activity.Green}",
$"{activity.Blue}"
};
}
protected override WitActivityColor DeserializeParameters(string[] parameters)
{
if (parameters.Length == 4)
return new WitActivityColor(parameters[0], int.Parse(parameters[1]), int.Parse(parameters[2]), int.Parse(parameters[3]));
throw new ExceptionOf<WitActivityColor>(Resources.IncorrectInput);
}
protected override WitActivityColor Clone(WitActivityColor activity)
{
return new WitActivityColor(activity.Color, activity.Red, activity.Green, activity.Blue);
}
protected override string Description => Resources["ColorDescription"];
protected override string ErrorMessage => Resources["ColorErrorMessage"];
}
Key Functions
- DeserializeParameters: Parses the function parameters from the script.
protected override WitActivityColor DeserializeParameters(string[] parameters)
{
if (parameters.Length == 4)
return new WitActivityColor(parameters[0], int.Parse(parameters[1]), int.Parse(parameters[2]), int.Parse(parameters[3]));
throw new ExceptionOf<WitActivityColor>(Resources.IncorrectInput);
}
- ProcessInner: Contains the main logic, creating a new WitColor object and assigning it to the pool of variables:
protected override void ProcessInner(WitActivityColor action, WitVariableCollection pool, ref string message)
{
pool[action.Color].Value = new WitColor(action.Red, action.Green, action.Blue);
}
Finally, register the activity adapter:
private void RegisterAdapters(IWitControllerManager controllers)
{
controllers.RegisterVariable(new WitVariableAdapterColor());
controllers.RegisterActivity(new WitActivityAdapterColor());
}
Now, you can run a script like this:
~Test Color Variable Job~
Job:ColorJob()
{
Color:color = WitColor(1, 2, 3);
Return(color);
}
Defining the Controller Module
To enable WitEngine to load a controller module, you need to define a class that connects the host with the module:
[Export(typeof(IWitController))]
public class WitControllerVariablesModule : IWitController
{
public void Initialize(IServiceContainer container)
{
InitServices(container);
RegisterAdapters(ServiceLocator.Get.ControllerManager);
}
private void InitServices(IServiceContainer container)
{
container.Resolve<IResourcesManager>()
.AddResourceDictionary(new ResourcesBase<Resources>(Assembly.GetExecutingAssembly()));
ServiceLocator.Get.Register(container.Resolve<ILogger>());
ServiceLocator.Get.Register(container.Resolve<IWitResources>());
ServiceLocator.Get.Register(container.Resolve<IWitControllerManager>());
ServiceLocator.Get.Register(container.Resolve<IWitProcessingManager>());
}
private void RegisterAdapters(IWitControllerManager controllers)
{
controllers.RegisterVariable(new WitVariableAdapterColor());
controllers.RegisterActivity(new WitActivityAdapterColor());
}
}
This class implements the IWitController
interface, and it’s crucial to annotate the class with the [Export]
attribute. Without this attribute, WitEngine won’t recognize or load the controller.
The key function in this class is Initialize
:
public void Initialize(IServiceContainer container)
{
InitServices(container);
RegisterAdapters(ServiceLocator.Get.ControllerManager);
}
The Initialize
function takes a service container as its parameter, which the host passes to the modules. One important service it provides is the IWitControllerManager
, which is responsible for registering adapters (for variables and activities) within WitEngine.
Conclusion
I’ve created a comprehensive demo project available on GitHub to help you explore the power and flexibility of WitEngine. The project includes the core WitEngine framework, along with two essential controller modules. The first module contains adapters for basic variable types: int
, double
, string
, and WitColor
(as discussed above). The second module offers adapters for core operations such as loops, parallel actions, delayed actions, and the Return
function (as we saw earlier), along with several others.
In addition to these modules, the project features a GUI designed to test the capabilities of WitEngine and to help you experiment with various scenarios. The GUI gives you a hands-on way to see how your scripts and controllers interact with the system in real time.
Here’s a screenshot of the GUI in action:
Feel free to explore the demo, test the controllers, and build your own controllers and scripts using WitEngine. I’ll be discussing other capabilities and use cases in future posts, so stay tuned for more detailed tutorials and examples.
You can find the full project and codebase here on GitHub.
Posted on October 24, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.