Modules
Every driver and plugin in Scadex is a module: an optional package with its own version, its own licence and its own log, which the gateway can start, stop, install and remove while it runs. This page is for engineers and administrators who operate a gateway. The module pages that follow describe each first-party module.
What a module is
A module is a folder with a project (a .NET assembly), a LICENSE and a module.json:
{
"id": "io.scadex.driver.mqtt",
"name": "MQTT Driver",
"version": "1.0.0",
"vendor": "scadex.io",
"license": "LicenseRef-Scadex-Proprietary",
"sdkVersion": "1.2",
"requiresLicense": false,
"assembly": "Scadex.Modules.Mqtt.dll"
}
| Field | Meaning |
|---|---|
id | Stable identity, reverse-DNS by convention. io.scadex.* is first-party; the scadex. prefix is reserved. Must match the id in the module's code. |
name | The name the dashboard shows. |
version | The module's own semantic version. It also becomes the assembly version. |
vendor | Who made it. |
license | An SPDX expression such as Apache-2.0, or a LicenseRef-* for proprietary terms. |
sdkVersion | The major.minor of Scadex.Sdk the module was built against. It loads on a gateway with the same major and the same or a newer minor. |
requiresLicense | Optional. When true, the module loads only as a signed package with a valid signed grant. See Licensing. |
assembly | The DLL to load. |
The gateway reads module.json before loading any code, so a disabled or incompatible module never executes. A module that fails to load is reported as failed with its error; the gateway and the other modules keep running.
Every module is optional. The gateway runs with any combination of them, including none, with the tags it already has.
First-party modules
| Id | Connects to | Contract |
|---|---|---|
io.scadex.driver.opcua (OPC UA) | OPC UA servers, with optional security and user credentials | SDK 1.2 |
io.scadex.driver.mqtt (MQTT) | MQTT brokers over mqtt, mqtts, ws and wss, with optional credentials | SDK 1.2 |
io.scadex.historian (Historian) | Nothing external: keeps accepted values in a file in its own data directory | SDK 1.2 |
io.scadex.cron (Cron jobs) | Nothing external: runs scripts and tag writes on a schedule | SDK 1.3 |
All four are at version 1.0.0, vendor scadex.io, licence LicenseRef-Scadex-Proprietary, and none requires a licence grant.
States
GET /api/v1/runtime/modules reports each module's state:
| State | Meaning |
|---|---|
starting, stopping | A change is in progress. Controls wait until it settles. |
running | Loaded and started. Its routes are live. |
stopped | An operator stopped it. It stays stopped when the gateway restarts, until it is started again (enabled is false). |
failed | It could not load or start, or threw while starting. error says why. Starting it again reads its folder afresh. |
disabled | Switched off in configuration. An operator cannot override that; lockedByConfiguration is true. |
incompatible | Its sdkVersion is not one this gateway provides. Refused before any code ran. |
unlicensed | A licence is required and no valid grant was found. |
unsigned | A licence is required and the package has no module.sig. |
tampered | The package has a module.sig that does not verify. |
Each module also reports its origin (built-in when it ships in the image, installed otherwise), whether it is removable, when it startedAt, how many restarts it has had, and a log summary with error and warning counts.
Lifecycle
- Stop cancels the module's work through its own
StopAsync, with 30 seconds to finish before the gateway moves on without it. Its routes and value reader are taken away, and every tag it owns is marked bad, keeping the last value, with a detail saying why. A stopped module stays stopped across gateway restarts. - Start and restart load the module's code into a fresh, collectible load context, so they always run the files on disk now. The module is expected to publish fresh values rather than assume the old ones are current.
- A module that throws, hangs or will not load ends up
failedorstoppedand nothing else. The gateway and the other modules carry on. - Operations on one module are serialised: a start cannot overlap a stop.
Choosing modules
At build time
SCADEX_MODULES picks which module folders go into the image:
docker compose build # all of them
SCADEX_MODULES="mqtt" docker compose build # just these folder names
SCADEX_MODULES="" docker compose build # core only
As a drop-in folder
Each subfolder of the /modules mount is one installed module. In compose.yaml that mount is ./.local/modules. Copy a module's published folder there and restart the gateway:
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 mqtt
docker compose restart gateway
A module there replaces a built-in one with the same id, including its stored connections and credentials, so treat write access to this folder as admin access. A broken folder never replaces a working built-in.
In configuration
SCADEX_DISABLED_MODULES (Modules__Disabled outside compose) is a comma-separated list of module ids to leave unloaded. Stored connections and tag bindings are kept for when the module is enabled again. A module disabled this way shows as disabled and cannot be started from the dashboard; only the configuration can change it.
At run time
Open Gateway, then System, then Modules (the Gateway group is shown to administrators; engineers reach the page at /modules). The page lists every module with its state, version, where it came from (Built in or Installed), how long it has been running and its error and warning counts. Only engineers and administrators can open it.
- Each row has Start, Restart and Stop buttons.
- Selecting a module opens a side panel with Overview, Logs and Settings. Overview shows version and vendor, where it came from, uptime, restarts, licence, kind, the SDK it was built for, its log summary, its value-stream position where it has one, and its capabilities.
- Settings has the Log level and, for installed modules, Remove module and Remove and delete its data.
- Logs shows the module's own log, with filters and a download of the kept log files.
- Administrators see Add module, which opens a panel to drop or pick a
.zippackage. The option Start it as soon as it is installed is on by default; leave it off to look at the module first. When the id is already installed, the dashboard asks before replacing it.
Old links to /settings/modules redirect to /modules.
Installing and removing
A package is a zip of the module's folder with module.json at its top, or inside one folder. The upload is unpacked beside the installed modules and held to every rule a module on disk is held to, and nothing in it runs until it passes: a valid manifest that matches the code, an SDK version this gateway provides, and, when the module declares a licence or carries a module.sig, a valid signature.
Refused with 400: paths that climb out of the folder, symbolic links, more than 5,000 files, more than 400 MB unpacked, and an upload over 200 MB. A refused package leaves nothing behind.
| Request | Role | Purpose |
|---|---|---|
GET /api/v1/runtime/modules | viewer | Every installed module and its state |
GET /api/v1/runtime/modules/{id} | viewer | One module |
POST /api/v1/runtime/modules/{id}/start | engineer | Starts a stopped or failed module; answers with the module, so check its state |
POST /api/v1/runtime/modules/{id}/stop | engineer | Stops it and keeps it stopped across restarts |
POST /api/v1/runtime/modules/{id}/restart | engineer | Stops, reloads the code and starts |
POST /api/v1/runtime/modules?replace=&start= | admin | Installs a module from a zip, sent as the multipart field file |
DELETE /api/v1/runtime/modules/{id}?purgeData= | admin | Removes an installed module |
- Installing an id that is already installed answers
409unlessreplace=true. With it the running module is stopped, its folder swapped for the new one, and the new one started; its connections, settings and tags are untouched. start=falseinstalls without starting; the module stays stopped until someone starts it.- An installed module shadows a built-in one with the same id, as a drop-in folder does.
- Removing deletes the package and stops the module. Built-in modules can be stopped but not removed (
409). Removing an installed module that shadowed a built-in hands over to the built-in again. - What the module stored (connections, settings, credentials, data directory and log) is kept so that installing it again picks up where it left off, unless
purgeData=true.
Tags are never deleted by removing a module. They stay bound to the module, marked bad, until someone deletes them, and the reply says how many are still bound.
Example upload from the command line:
(cd .local/modules && zip -r ../mqtt.zip io.scadex.driver.mqtt)
curl -b cookies.txt -H "Origin: https://app.example.com" -F file=@.local/mqtt.zip \
"https://app.example.com/api/v1/runtime/modules?replace=true"
Module logs
Every module writes through the logger the gateway gives it, and the gateway keeps that log apart from its own.
- The last 2,000 lines of each module are held in memory for the dashboard and the API.
- Each module also writes rotating files at
DATA_DIR/logs/modules/{id}/module.log, up to 2 MB each with the newest five kept. They survive restarts. - Each line has a time, level, message, optional exception, and the
instanceit concerns (a connection, a broker) when the module wrote it under a scope or with an instance name. Lines the gateway wrote about the module (started, stopped, failed, who asked, which tags it marked bad) are in the same log withsource: "host". - Each module has its own level,
informationby default. Changing it takes effect at once and is remembered inDATA_DIR/modules.json, which also remembers which modules an operator stopped. Delete that file to forget both. - Credentials are masked before a line is kept, shown or written:
password=,secret=,token=,api-key=andauthorization:values, the password inscheme://user:password@host, and bearer tokens. Only these common shapes are recognised. - The gateway's own log (Diagnostics, then Logs) does not carry module lines. They still reach the container's console under the category
Module.{id}.
| Request | Role | Purpose |
|---|---|---|
PUT /api/v1/runtime/modules/{id}/log-level | engineer | Body {"level": ...}: debug, information, warning, error (also trace, critical) |
GET /api/v1/runtime/modules/{id}/logs | engineer | Newest first, filtered by level, search, instance, before, after, limit; nextBefore pages |
GET /api/v1/runtime/modules/{id}/logs/stream?level&after | engineer | Server-sent events: the backlog since after, then each line as it is written, with a keepalive every 15 seconds |
GET /api/v1/runtime/modules/{id}/logs/download | engineer | The kept log files, oldest first, as text |
Module routes
A module may expose its own HTTP routes. The gateway maps them beneath /api/v1/modules/{id}, applies the role policies (a GET needs viewer, other methods need engineer unless the module marks them as writing tag values), and reports them in GET /api/v1/runtime/modules. Drivers also get a baseline set of connection routes mapped for them; see Writing a module.
A module's routes exist while it runs. While a module is stopped, starting or failed, its URLs answer 503 "Module unavailable" rather than 404, so a caller can tell a stopped module from a missing one. A module that was never loaded (disabled in configuration, unlicensed, refused) has no routes and answers 404.
In this section
- OPC UA: connect OPC UA servers and bind their nodes to tags.
- MQTT: connect brokers and turn topics into tags.
- Historian: keep every accepted value for 90 days.
- Cron jobs: run scripts and tag writes on a schedule.
- Writing a module: the SDK contracts for developers.
- Licensing: per-component licences, grants and signed packages.