Skip to main content
Version: 0.1

Writing a module

This page is for developers who want to add a driver or plugin to the gateway. It describes the module folder, the contracts in Scadex.Sdk and Scadex.Sdk.AspNetCore (contract 1.3), the lifecycle rules the gateway holds a module to, and how to package and install one. Modules run as trusted in-process code; the restricted contracts prevent accidental routing and policy mistakes, not a hostile assembly.

The module folder​

A module is a folder under modules/ with three things:

modules/mymodule/
module.json identity, version, licence, sdkVersion, assembly
LICENSE the module's own licence text
Scadex.Modules.MyModule.csproj
*.cs

The fields of module.json are listed on Modules. version is the single source of truth: modules/Directory.Build.props reads it into the assembly version and sets EnableDynamicLoading. modules/Directory.Build.targets references Scadex.Sdk and Scadex.Sdk.AspNetCore for you with Private="false" and ExcludeAssets="runtime", because the host supplies them at run time, and copies module.json and LICENSE to the output.

Do not add project references. tests/Scadex.Core.Tests/ModuleBoundaryTests fails the build when a module reaches past the SDK. NuGet packages are fine: publishing puts them in the module's own folder. Third-party modules build the same way against the two SDK projects, which are packable with dotnet pack but are not published to a feed yet.

A module's stored state (context.State), including connection passwords, is encrypted for it by the gateway.

Contracts​

IScadexModule​

Every module implements this:

public interface IScadexModule
{
ModuleManifest Manifest { get; }
ValueTask StartAsync(IModuleContext context, CancellationToken cancellationToken);
ValueTask StopAsync(CancellationToken cancellationToken);
}

public sealed record ModuleManifest(
string Id,
string Name,
ScadexModuleKind Kind, // Driver, Historian, Processor, Exporter, Integration
string Description,
IReadOnlyCollection<string>? Capabilities = null,
string? Technology = null,
DriverExperienceDescriptor? Driver = null,
bool RequiresLicense = false);

Manifest.Id must equal id in module.json.

IModuleContext​

What the gateway hands to StartAsync:

MemberMeaning
ITagRuntime TagsList the tags this module owns (ListOwnedAsync), publish values (PublishAsync), read a current state (GetCurrentAsync) and write through the gateway (WriteAsync, audited under the command's PrincipalId).
IModuleStateStore StateDurable, module-scoped key/JSON entries: ListAsync(prefix), GetAsync, PutAsync, DeleteAsync. The gateway stores the JSON opaquely and includes it in backups.
ILogger LoggerThe module's own log; see below.
string DataDirectoryA private, persistent directory for the module's files (DATA_DIR/modules/{id}).
LicenseGrant? LicenseThe valid signed grant installed for this module's id, or null. See Licensing.
IModuleScripts? ScriptsThe gateway's script sandbox, or null on a gateway that offers none. Added in contract 1.3.

Optional interfaces a module may add:

InterfacePurpose
IModuleBindingListenerBindingsChangedAsync(instanceIds): the tags bound to these instances changed.
IModuleTagRestartHandlerRestartTagsAsync(instanceId, tagPublicIds): drop and re-create whatever is live for these tags so the source sends current values. Called from the Restart action and after a type or tag edit.
IModuleWriteHandlerWriteAsync(ModuleOwnedTag, TagWriteCommand): handle a write to a tag the module owns and answer a TagWriteResult(Accepted, Update, Error).
IDriverConnectionStatesGetConnectionStatesAsync(): report every connection, switched-off ones too, for the @gateway.connection.<kind>.<name> tags. Called every few seconds; answer from memory, never by reaching the device.

A ModuleOwnedTag is the tag's TagMetadata, its InstanceId and its opaque Binding JSON. The gateway never interprets a binding.

IScadexApiModule​

Declared in Scadex.Sdk.AspNetCore:

public sealed class ExampleModule : IScadexModule, IScadexApiModule
{
public void MapEndpoints(IModuleEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/status", () => new { running = true })
.WithName("status")
.WithSummary("Get module status")
.Produces(200, "application/json");
}

// Manifest, StartAsync and StopAsync omitted.
}

IModuleEndpointRouteBuilder offers MapGet, MapPost, MapPut, MapPatch and MapDelete, each returning an IModuleEndpointConventionBuilder with WithName, WithSummary, WithDescription, Produces(statusCode, contentType) and WritesTagValues(). It does not expose ASP.NET's raw route builder, so a module cannot leave its namespace or switch off authorization.

The gateway maps every handler beneath /api/v1/modules/{module-id} (so the example is GET /api/v1/modules/acme.example/status), rejects unsafe or duplicate patterns, and applies the role policy: a GET needs viewer and any other method needs engineer, unless the handler declares .WritesTagValues(), which marks a mutating endpoint as writing tag values rather than changing configuration, so operators may call it too. Such a handler should write through ITagRuntime.WriteAsync with the caller as principal, so the write is audited. Routes exist while the module runs and answer 503 while it is stopped.

IScadexDriverModule​

A driver implements IScadexDriverModule : IScadexModule and publishes a DriverExperienceDescriptor:

public sealed record DriverExperienceDescriptor(
string ConnectionLabel, // "server", "broker"
string ConnectionLabelPlural,
IReadOnlyList<DriverConfigurationField> ConnectionFields,
string? RootBrowseId = null);

public sealed record DriverConfigurationField(
string Key,
string Label,
DriverConfigurationFieldType Type, // Text, Secret, Number, Boolean, Select
bool Required = false,
string? Placeholder = null,
string? DefaultValue = null,
string? Help = null,
IReadOnlyList<DriverConfigurationOption>? Options = null);

The dashboard renders the connection form from these fields without knowing the technology. The driver then implements ListConnectionsAsync, GetConnectionAsync, CreateConnectionAsync, UpdateConnectionAsync, DeleteConnectionAsync, TestConnectionAsync (DriverConnectionTestResult(Success, Status, Message, LatencyMs)), ListLinkedTagsAsync, BrowseAsync (DriverBrowseResult(Status, Items, Message, ContinuationToken); each DriverBrowseItem has Id, Label, Kind, HasChildren, DataType, an opaque Binding and Metadata) and DiscoverAsync, which turns selected source ids into DriverTagProposals (SourceId, SuggestedPath, Kind, DataType, Binding). The dashboard submits proposals to the gateway's tag API, so the gateway owns tag identity.

The gateway maps the baseline driver routes for every driver, through DriverModuleEndpointExtensions.MapStandardDriverEndpoints:

RequestHandler
GET /driverThe descriptor
GET|POST /connectionsList, create
GET|PUT|DELETE /connections/{connectionId}Get, update, delete
POST /connections/{connectionId}/testTest
GET /connections/{connectionId}/tagsLinked tags
POST /connections/{connectionId}/browseBrowse
POST /connections/{connectionId}/discoverProposals

A driver may add IScadexApiModule routes for technology-specific operations, as the OPC UA module does for type references. Connection states for the @gateway.connection tags come from IDriverConnectionStates, each a DriverConnectionState(Id, Name, Address, Enabled, Connected, TagCount, Error, Details); Address must never hold a password, and each Details entry becomes a tag of its own. ConnectionMeter in the SDK counts values, messages and tag qualities for you.

IModuleValueConsumer​

public interface IModuleValueConsumer
{
ValueTask<ulong> GetValuePositionAsync(CancellationToken cancellationToken);
ValueTask ConsumeValuesAsync(ModuleValueBatch batch, CancellationToken cancellationToken);
}

public sealed record ModuleValueBatch(
IReadOnlyList<ModuleValue> Values, // ModuleValue(string? Path, TagValueUpdate Update)
ulong Position,
ulong StreamHead,
ulong MissedMessages = 0);

The gateway reads the value stream once per consumer, from the position the consumer reports (0 means the oldest retained value), on its own task. Delivery is at-least-once and in stream order. A batch that throws is retried after a delay; store Position atomically with the data where you can. StreamHead - Position is how far behind the consumer is; a non-zero MissedMessages means retention removed messages before the consumer read them, a real gap. A Path of null means the tag has since been deleted. GET /api/v1/runtime/modules reports each consumer's position, lag and last error.

A module implementing IModuleHistoryProvider (QueryHistoryAsync(ModuleHistoryQuery) returning ModuleHistoryResult) answers history reads. The first-party historian uses exactly these two contracts and has no other access to the gateway.

Running scripts​

Since contract 1.3, context.Scripts lends a module the gateway's sandbox:

var result = await context.Scripts!.RunAsync(new ModuleScriptRequest(
"writeTag('Line1.Count', 0); console.log('reset'); return 'ok';",
PrincipalId: "cron:Reset counters",
EventName: "Reset counters"), ct);
// result.Ok, result.Error, result.Value, result.Writes, result.Logs, result.Duration

ModuleScriptRequest also takes Parameters (seen as event.params and substituted into {name} in tag paths), DryRun (report the writes, apply none) and CorrelationId. A module script has 5 seconds of processing, 2 million statements and 32 MiB; module scripts run one at a time across the gateway. Writes are applied once the script finishes, in order, stopping at the first failure, and each is audited under PrincipalId. A module that uses Scripts declares sdkVersion 1.3, so it never loads on an older gateway where the member would be missing.

Lifecycle rules​

A module can be stopped, started and restarted while the gateway runs, each time as a fresh instance in a fresh, collectible load context.

  • StopAsync must end everything the module started: cancel its tasks, close its connections and files. It gets 30 seconds, then the gateway moves on without it. A task that outlives StopAsync keeps the old code in memory and keeps running.
  • Keep state in context.State or context.DataDirectory, not in statics. A restart is a new instance.
  • When the module stops, the gateway marks every tag it owns bad. On start, publish fresh values; do not assume the old ones are current.
  • A module that throws in StartAsync ends up failed with the error shown; the gateway and the other modules carry on.

Logging​

Log with context.Logger. Each module's lines are kept apart from the gateway's (2,000 lines in memory, rotating files under DATA_DIR/logs/modules/{id}/), with a level an operator can change at run time, so check IsEnabled or use the extension methods. Put the connection or instance a line is about in a scope (BeginScope(new { Instance = name })) or in a {Name}, {Instance} or {Key} placeholder, and the line becomes filterable by instance in the dashboard and the API. Do not log secrets: credentials are masked, but only the common shapes.

Packaging​

scripts/stage-modules.sh [configuration] [target] [modules] publishes modules as drop-in folders, <target>/<module-id>/ with the DLLs, module.json and LICENSE. modules is all (the default), a space- or comma-separated list of folder names under modules/, or empty. Run it inside the .NET SDK image so no local toolchain is needed:

mkdir -p .local/modules
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/src" -w /src \
mcr.microsoft.com/dotnet/sdk:10.0.403 scripts/stage-modules.sh Release .local/modules mymodule

Zip the staged folder and install it through the API (administrator role), or drop the zip on the Modules page:

(cd .local/modules && zip -r ../mymodule.zip com.acme.mymodule)
curl -b cookies.txt -H "Origin: https://app.example.com" -F file=@.local/mymodule.zip \
"https://app.example.com/api/v1/runtime/modules?replace=true"

The rules the upload is held to (size, file count, no symlinks, matching manifest, SDK version, signature) are on Modules. A module that requires a licence must also be signed; see Licensing.

Adding a first-party module to the repository​

  1. Copy modules/mqtt/ to modules/<name>/ and give it a new module.json.
  2. Add the project to Scadex.slnx under the /modules/ folder.
  3. Reference it from tests/Scadex.Core.Tests and add an instance to ModuleBoundaryTests.Modules, the array that holds new CronModule(), new HistorianModule(), new MqttModule() and new OpcUaModule().
  4. Build the image with SCADEX_MODULES naming the folder, or stage it as a drop-in.