- 12 Nov 2025
- 28 Minutes to read
- Print
- PDF
Examples
- Updated on 12 Nov 2025
- 28 Minutes to read
- Print
- PDF
Inserting tag values into a MySQL database
Scripting can be used to connect to MySQL by using the mysql internal library. More information about internal libraries can be found at Libraries.
The following example shows how to insert tag changes into the database. This assumes a database and a table are already created with the following schema:

In order to insert the events, two scripts will be created, one serving as a wrapper over the raw MySQL connection (as a static library), and one startup script that will subscribe to events and export them to the database.
The following libraries configuration is used:

The mysql_connector static library contains the following script:
// Import the MySQL internal library
const mysql = $.lib("mysql");
// Export the MySqlConnection class
module.exports = class MySqlConnection {
// Constructor of the class taking a MySQL client instance
constructor(client) {
this._client = client;
// Error event handler that logs a message to the logger
client.on("error", (err) => {
$.logger.warn(
"Error on client: %s (%d - %s)",
err.message,
err.errno,
err.sqlMessage
);
// Additionally, if the error is fatal, the client is dropped
if (err.fatal) {
this._client = null;
}
});
}
// This function must be called when the client is no longer being used
// in order to terminate the connection
dispose() {
return new Promise((resolve, reject) => {
if (this._client) {
// If the client is online, end the client
this._client.end((err) => {
if (err) {
reject(err);
} else {
resolve();
}
this._client = null;
});
} else {
reject(new Error("Connection already terminated"));
}
});
}
// Checks whether the client is online or not
online() {
return this._client !== null;
}
// This function executes a query and resolve to a promise
// containing a tuple with the first element being the result
// of the query, and the second element the fields returned from
// the query
query(query, ...params) {
return new Promise((resolve, reject) => {
if (this._client) {
this._client.query(query, params, (err, result, fields) => {
if (err) {
//If the error is fatal, set the client to null
if (err.fatal) {
this._client = null;
}
reject(err);
} else {
resolve([result, fields]);
}
});
} else {
reject(new Error("Connection is terminated"));
}
});
}
// Static method used to asynchronously create a new client, which is necessary since creating a connection
// is an asynchronous operation
static Connect(connOpts) {
return new Promise((resolve, reject) => {
// Create the client with the provided options
const client = mysql.createConnection(connOpts);
// Connect the client to the DB
client.connect((err) => {
if (err) {
reject(err);
} else {
// If the connection is OK, create a MySqlConnection instance using the client
// and resolve the promise with that instance
const connection = new MySqlConnection(client);
resolve(connection);
}
});
});
}
};
The startup script that relies on the library and is responsible for sending the event uses the following code:
const MySqlConnection = $.lib("mysql_connector");
// Define the options used to connect to MySQL
const OPTIONS = {
host: "localhost",
port: 3306,
user: "admin",
password: "n3uron",
database: "n3_scripting",
};
// Define insertion period
const PERIOD = 10000;
// Define the tags that will be inserted into the DB
const TAGS = ["/Simulated/String", "/Simulated/Bool", "/Simulated/Number"];
// Subscribe to the tags
const subscription = await $.api.tag.subscribe(TAGS, onTagEvent);
// Buffer that holds the events between insertions
const events = [];
// Database client
let client = null;
//Set a 10 seconds timer before pushong the events
setTimeout(pushEvents, PERIOD);
//Handler that gets called whenever the tags have a new event
function onTagEvent(tag, data) {
//Save the event to the buffer
events.push({ tag, value: data.value, quality: data.quality, ts: data.ts });
}
// Async function called every PERIOD milliseconds to insert data into the DB
async function pushEvents() {
// If the client is null or offline, connect the client
if (!client || (client && !client.online())) {
try {
await connect();
} catch (ex) {
// If the connection fails, schedule a new timer
$.logger.warn("Error connecting: %s", ex.message);
return setTimeout(pushEvents, PERIOD);
}
}
// If the client is connected, remove the events from the buffer
// and transform them into a format that can be used by MySQL
const ev = events.splice(0).map((el) => {
// Set number_ev, string_ev and bool_ev either to the event
// or to null if the type does not match
const number_ev = typeof el.value === "number" ? el.value : null;
const string_ev = typeof el.value === "string" ? el.value : null;
const bool_ev = typeof el.value === "boolean" ? el.value : null;
// Return a 6 element tuple representing the values that will be inserted into the DB
return [el.tag, number_ev, string_ev, bool_ev, el.quality, new Date(el.ts)];
});
try {
$.logger.debug("Inserting %d events", ev.length);
// Execute an INSERT query using ? as placeholder for the values
await client.query(
`INSERT into tag_events (tag, number_value, string_value, bool_value, quality, ts) VALUES ?`,
ev
);
$.logger.debug("Inserted OK", ev.length);
} catch (ex) {
$.logger.warn("Error during query: %s", ex.message);
// If the query fails and the client is offline, null the client
if (!client.online()) {
client = null;
}
} finally {
// After finishing the query (whether it was successful or not)
// schedule a new timer
setTimeout(pushEvents, PERIOD);
}
}
// Creates a new MySQL client
async function connect() {
$.logger.debug(
"Connecting to MySQL using %s@%s:%d",
OPTIONS.user,
OPTIONS.host,
OPTIONS.port
);
client = await MySqlConnection.Connect(OPTIONS);
$.logger.debug("Connected OK");
}
Reading tag values from Microsoft SQLServer database
This example reads data from a Microsoft SQL Server database and saves it to Scripting source tags. This is done using the mssql NPM library, which is integrated in N3uron (and thus can be used as an internal library).
This example assumes the target table has the following schema, and that it's data it's being updated from an external source:

In this example, the table has the following data:
This data will be saved to Scripting source tags using an alias, this allows the tags in N3uron to have a different structure to those in Microsoft SQL Server. A configuration of a source tag can be seen in the next screenshot:

This example will use two scripts (a periodic script and a static library) as well as an internal library. The following two libraries are used:

The mssql_connector static library has the following script:
// Import the Microsft SQL Server internal library
const mssql = $.lib("mssql");
// Export the SqlServerConnection class
module.exports = class SqlServerConnection {
// Constructor of the class taking a SqlServerConnection client instance
constructor(client) {
this._client = client;
// Error event handler that logs a message to the logger and sets the client to null
client.on("error", (err) => {
$.logger.warn("Error on client: %s", err.message);
// Drop the client after an error
this._client = null;
});
}
// This function must be called when the client is no longer being used
// in order to terminate the connection
dispose() {
if (this._client) {
this._client.close();
this._client = null;
} else {
throw new Error("Connection is already closed");
}
}
// Checks whether the client is online or not
online() {
return this._client !== null;
}
// This function executes a query and resolve to a promise
// containing a tuple with the first element being the result
// of the query, and the second element the fields returned from
// the query
async query(query, ...params) {
if (this._client) {
// Creates a new request on the connection, and executes the query
return await new mssql.Request(this._client).query(query);
} else {
throw new Error("Connection is offline");
}
}
// Static method used to asynchronously create a new client, which is necessary since creating a connection
// is an asynchronous operation
static async Connect(connOpts) {
//Create the client with the provided options and connect with DB
const client = await mssql.connect(connOpts);
//If the connection is OK, create a SqlServerConnection instance
//using the client and return it
return new SqlServerConnection(client);
}
};
The periodic script has a 10-second trigger, and it's used to retrieve data from the database and save it to the source tags. It has the following code:
const SqlServerConnection = $.lib("mssql_connector");
// Define the options used to connect to Microsoft SQL Server
const OPTIONS = {
server: "10.1.3.169",
port: 1433,
user: "sa",
password: "n3uron$!",
database: "n3_scripting_manual",
};
// If the local variables are not initialized, initialize them
if ($.local.init === undefined) {
await init();
}
// Retrieve the data, and exit the script after the async call is finish
await getData();
// Async function used to retrieve the data
async function getData() {
// If the client is not online, attempt to reconnect
if ($.local.client === null || !$.local.client.online()) {
const client = await connect();
// If the client connects OK, save it to $.local.client and keep going
if (client !== null) {
$.local.client = client;
} else {
// If the client is null, it means the connection was unsuccessful, so the query can't be executed
return;
}
}
try {
// Try to retrieve the data using a SELECT query
const queryResult = await $.local.client.query(
"SELECT tag, value FROM dbo.data"
);
// For every row in the result, destructure the row object into the tag and value variables
// and execute a tag alias update
for (const { tag, value } of queryResult) {
$.api.tag.updateAlias(tag, value);
}
} catch (ex) {
// If the query has an error, log the error
$.logger.warn("Error during query: %s", ex.message);
}
}
// Creates and returns a new Microsoft SQL Server client
async function connect() {
$.logger.debug(
"Connecting to Microsoft SQL Server using %s@%s:%d",
OPTIONS.user,
OPTIONS.server,
OPTIONS.port
);
try {
const client = await SqlServerConnection.Connect(OPTIONS);
$.logger.debug("Connected OK");
return client;
} catch (ex) {
$.logger.warn("Error connecting client: %s", ex.message);
return null;
}
}
// Initializes the required local variables
async function init() {
$.local.init = true;
// The client must be saved in a local variable to only has one instance
// that persists between script executions
$.local.client = null;
}
Creating a table and inserting tag values into a Microsoft SQL database
In this example, we define the table schema directly within the script and insert tag values into a Microsoft SQL Server database. This is done using the mssql NPM library, which is integrated in N3uron (and thus can be used as an internal library).
This example will use two scripts (a periodic script and a static library) as well as an internal library. The following two libraries are used:
The mssql_connector static library has the following script:
// Import the Microsft SQL Server internal library
const mssql = $.lib("mssql");
// Export the SqlServerConnection class
module.exports = class SqlServerConnection {
// Constructor of the class taking a SqlServerConnection client instance
constructor(client) {
this._client = client;
// Error event handler that logs a message to the logger and sets the client to null
client.on("error", (err) => {
$.logger.warn("Error on client: %s", err.message);
// Drop the client after an error
this._client = null;
});
}
// This function must be called when the client is no longer being used
// in order to terminate the connection
dispose() {
if (this._client) {
this._client.close();
this._client = null;
} else {
throw new Error("Connection is already closed");
}
}
// Checks whether the client is online or not
online() {
return this._client !== null;
}
// This function executes a query and resolve to a promise
// containing a tuple with the first element being the result
// of the query, and the second element the fields returned from
// the query
async query(query, ...params) {
if (this._client) {
// Creates a new request on the connection, and executes the query
return await new mssql.Request(this._client).query(query);
} else {
throw new Error("Connection is offline");
}
}
// Static method used to asynchronously create a new client, which is necessary since creating a connection
// is an asynchronous operation
static async Connect(connOpts) {
//Create the client with the provided options and connect with DB
const client = await mssql.connect(connOpts);
//If the connection is OK, create a SqlServerConnection instance
//using the client and return it
return new SqlServerConnection(client);
}
};This periodic script, triggered every 10 seconds, establishes the connection settings to the database, creates the table if it doesn't already exist, and inserts data from the tags. The script includes the following code:
const SqlServerConnection = $.lib("mssql_connector");
// Define the options used to connect to Microsoft SQL Server
const OPTIONS = {
database: "Test",
server: "127.0.0.1",
connectionTimeout: 3000,
port: 1433,
authentication:{
type:"default",
options:{
userName: "sa",
domain: null,
password: "n3uron$!",
}
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 100000
},
options: {
encrypt: false,
trustServerCertificate: false // change to true for local dev / self-signed certs
}
};
// Define insertion period
const PERIOD = 10000;
// Define the tags that will be inserted into the DB
const TAGS = ["/VoltageBR", "/VoltageRY", "/VoltageYB"];
// Subscribe to the tags
const subscription = await $.api.tag.subscribe(TAGS, onTagEvent);
// Buffer that holds the events between insertions
const events = [];
// Database client
let client = null;
// Set a 10-second timer before pushing the events
setTimeout(pushEvents, PERIOD);
// Handler that gets called whenever the tags have a new event
function onTagEvent(tag, data) {
// Save the event to the buffer
events.push({ tag, value: data.value, quality: data.quality, ts: data.ts });
}
// Async function to ensure the table exists before starting data insertion
async function ensureTableExists() {
const createTableQuery = `
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='TAG_EVENTS' AND xtype='U')
CREATE TABLE TAG_EVENTS (
id INT PRIMARY KEY IDENTITY(1,1),
tag NVARCHAR(255) NOT NULL,
number_value FLOAT NULL,
string_value NVARCHAR(MAX) NULL,
bool_value BIT NULL,
quality TINYINT NOT NULL, -- Changed to TINYINT to store values from 0-255
ts DATETIME2 NOT NULL
);`;
$.logger.debug("Create table query: %s", createTableQuery);
try {
await client.query(createTableQuery);
$.logger.debug("Ensured that table TAG_EVENTS exists.");
} catch (ex) {
$.logger.warn("Error ensuring table exists: %s", ex.message);
}
}
// Async function called every PERIOD milliseconds to insert data into the DB
async function pushEvents() {
// If the client is null or offline, connect the client
if (!client || (client && !client.online())) {
try {
$.logger.debug("Connecting");
await connect();
$.logger.debug("ConnectOk");
$.logger.debug("Creating Table");
await ensureTableExists(); // Ensure the table exists after connecting
$.logger.debug("Table Created");
} catch (ex) {
// If the connection fails, schedule a new timer
$.logger.warn("Error connecting: %s", ex.message);
return setTimeout(pushEvents, PERIOD);
}
}
// If the client is connected, remove the events from the buffer
// and transform them into a format that can be used by SQL Server
const ev = events.map((el) => {
// Set number_ev, string_ev and bool_ev either to the event
// or to null if the type does not match
const number_ev = typeof el.value === "number" ? el.value : null;
const string_ev = typeof el.value === "string" ? el.value : null;
const bool_ev = typeof el.value === "boolean" ? el.value : null;
// Return a 6-element array representing the values that will be inserted into the DB
const values = [
`'${el.tag}'`,
number_ev !== null ? number_ev : 'NULL',
string_ev !== null ? `'${string_ev}'` : 'NULL',
bool_ev !== null ? bool_ev : 'NULL',
el.quality,
`'${new Date(el.ts).toISOString()}'`
];
return `(${values.join(", ")})`;
});
try {
$.logger.debug("Inserting %d events", ev.length);
// Execute an INSERT query
const query = `INSERT INTO TAG_EVENTS (tag, number_value, string_value, bool_value, quality, ts) VALUES ${ev.join(",")}`;
$.logger.debug("Query %s", query);
await client.query(query);
$.logger.debug("Inserted OK", ev.length);
} catch (ex) {
$.logger.warn("Error during query: %s", ex.message);
// If the query fails and the client is offline, null the client
if (!client.online()) {
client = null;
}
} finally {
// After finishing the query (whether it was successful or not)
// schedule a new timer
setTimeout(pushEvents, PERIOD);
}
}
// Creates a new SQL Server client
async function connect() {
$.logger.debug(
"Connecting to SQL Server using %s@%s:%d",
OPTIONS.user,
OPTIONS.server,
OPTIONS.port
);
client = await SqlServerConnection.Connect(OPTIONS);
$.logger.debug("Connected OK");
}
// Initializes the required local variables
async function init() {
$.local.init = true;
// The client must be saved in a local variable to only have one instance
// that persists between script executions
$.local.client = null;
}The following screenshot shows the configuration of the Scripting task, where the script is triggered at startup or whenever the Scripting module is restarted. After initialization, the script performs data insertion into the database every 10 seconds. The execution period and tag subscriptions are hardcoded within the script, but these settings can be parameterized if needed using Parameters.
Writing data to XLSX files
This example reads data from Historian, using the history API call, and stores it to a template XLSX file that can be used for further data processing. This example will use the following XLSX file:

The Processed Data worksheet contains an average function over all the rows (skipping the first row, which are going to be used as the headers) of column B of _Data, while the _Data worksheet is empty, and it will be automatically filled by N3uron based on data from Historian.
In order to have access to the XLSX writer library, an internal library instance must be created:

Finally, the following script is used to retrieve events from Historian, and insert them as well as the headers:
// Load the XLSX library
const Workbook = $.lib("xlsx");
const tag = "/Memory/Historical";
// Retrieve 1 hour of data
const end = Date.now();
const start = end - 3600000;
const history = await $.api.tag.history(tag, start, end, { mode: "raw" });
// Open the template workbook
const workbookTemplatePath = String.raw`C:\Dev\Manual.xlsx`;
$.logger.debug("Opening workbook...");
const workbook = await Workbook.Open(workbookTemplatePath);
// Retrieve the _Data worksheet
$.logger.debug("Workbook opened OK, retrieving worksheet...");
const sheet = await workbook.getSheet("_Data");
$.logger.debug("Worksheet retrieved OK, inserting data...");
// Write the headers in the first row
sheet.writeData([["Timestamp", tag]], 0, 0);
// Parse the data as necessary
const cellData = [];
for (const [ts, value] of history.data) {
cellData.push([ts, value]);
}
// Write the data, setting the startRow to 1 in order to not overwrite the headers
sheet.writeData(cellData, 1, 0);
// Save the workbook to a different path to avoid overwriting the template
const workbookOutPath = String.raw`C:\Dev\Manual_modified.xlsx`;
$.logger.debug("Data inserted OK, saving workbook...");
await workbook.save(workbookOutPath);
$.logger.debug("Workbook saved OK");
Monitoring External Device Connectivity with Ping-Based Checks
This monitoring example illustrates how to assess the connectivity of external devices—such as routers or other networked components that are not fieldbus devices—by implementing a ping-based connectivity check. By regularly pinging these devices, you can ensure they are reachable and operational within your network.
- Step 1: Create a Tag and assign its type to Boolean and its source to the Scripting module.

- Step 2: Create a Task within the instantiated Scripting module, set its Action to Periodic, and configure its execution period. The period can be defined either as a time interval in milliseconds or using a Cron expression, depending on your specific requirements.

- Step 3: Create two parameters: one of type String to specify the IP address of the device you want to monitor, and another of type TagPath to select the path of the tag, which will represent the connectivity status of that device.
.png)
- Step 4: Copy and paste the following code into the Javascript code:

Sample code:JavaScript// Import the 'child_process' module to execute system commands like 'ping' const { exec } = require('child_process'); // Import the 'os' module to identify the operating system const os = require('os'); /** * Sends a single ping to the specified host to check connectivity. * * Constructs and executes a ping command based on the operating system. * Resolves with true if the host is reachable, otherwise false. * * host - The target IP address or hostname. * timeout - The duration to wait for a response in seconds. * returns Indicates if the host is reachable. */ function pingHost(host = '8.8.8.8', timeout = 5) { return new Promise((resolve) => { // Determine the current OS platform (e.g., 'win32', 'linux', 'darwin') const platform = os.platform(); // Build the appropriate ping command based on the OS let pingCommand = ''; if (platform === 'win32') { // For Windows: // '-n 1' sends one echo request // '-w ' sets the timeout in milliseconds pingCommand = `ping -n 1 -w ${timeout * 1000} ${host}`; } else { // For Unix-like systems: // '-c 1' sends one packet // '-W ' sets the timeout in seconds pingCommand = `ping -c 1 -W ${timeout} ${host}`; } // Execute the ping command exec(pingCommand, (error, stdout) => { if (error) { // If the ping command fails, log a warning and resolve as not connected $.logger.warn(`Ping command failed: ${error.message}`); resolve(false); return; } // Analyze the ping output to determine connectivity const isConnected = parsePingOutput(platform, stdout); resolve(isConnected); }); }); } /** * Analyzes the output of the ping command to determine connectivity. * * Checks for specific strings in the ping output that indicate a successful ping. * * platform - The operating system platform. * stdout - The output from the ping command. * returns True if the host is reachable, false otherwise. */ function parsePingOutput(platform, stdout) { if (platform === 'win32') { // On Windows, a successful ping includes 'TTL=' return stdout.includes('TTL='); } else { // On Unix-like systems, a successful ping includes '1 received' or 'bytes from' return ( stdout.includes('1 received') || stdout.includes('bytes from') ); } } /** * Updates the specified tag with the connectivity status. * * Writes true or false to the given tag path with appropriate quality values. * Logs the outcome of the write operation. * * tagPath - The tag identifier to update. * status - The connectivity status. */ async function writeConnectivityStatus(tagPath, status) { try { // Determine the quality value based on connectivity status const quality = status ? 192 : 24; // Update the tag with the status and current timestamp await $.api.tag.update(tagPath, status, quality, Date.now()); // Log the successful update $.logger.info(`Connectivity status (${status}) written to tag: ${tagPath}`); } catch (error) { // Log any errors that occur during the tag update $.logger.error(`Failed to write to tag ${tagPath}: ${error.message}`); throw error; } } /** * Executes the connectivity check and updates the corresponding tag. * * Retrieves the target IP address and tag path from parameters, * performs the ping, and writes the result to the tag. */ async function performConnectivityCheck() { try { // Get the target device's IP address and tag path from parameters const ip_address = $.parameters.ip_address; // Device IP address const tagPath = $.parameters.tagPath; // Tag path for connectivity status // Log the initiation of the connectivity check $.logger.info(`Pinging ${ip_address} to check connectivity...`); // Perform the ping to determine if the device is reachable const isConnected = await pingHost(ip_address, 5); // Update the tag with the connectivity status await writeConnectivityStatus(tagPath, isConnected); } catch (error) { // Log any unexpected errors during the connectivity check $.logger.error(`Connectivity check error: ${error.message}`); } } // Start the connectivity check process performConnectivityCheck();
Building a Real-Time Dashboard with N3uron Scripting Assistant in ChatGPT
This example was created using the N3uron Scripting Assistant in ChatGPT. It demonstrates how to build a real-time web dashboard for a solar power plant by leveraging the N3uron Scripting module together with the Web Vision PV Demo Project as its data source.
This dashboard presents live Key Performance Indicators (KPIs) for both the plant level and each station level, updating automatically as tag values change in real time. It features a professional dark-themed interface with dynamic gauges, quality indicators, and a responsive design that provides operators with up-to-date information directly from any web browser.
Prompt Used (watch the video):
I need to create a real-time web dashboard for our solar plant monitoring using N3uron's Scripting module.
Here's a screenshot of our tag structure [attach image of tag tree].
Our setup:
- Plant: BLUELAKE
- Plant Group: PVG001
- 10 Stations: PST_01 through PST_10
I need two main sections:
1. GENERAL SECTION (plant-level KPIs):
- Active Power Total (kW)
- Active Power 5min Average (kW)
- Active Power Hour Average (kW)
- Active Energy Day (kWh)
- Active Energy Total (kWh)
- Reactive Power Total (VAr)
- POA Irradiance (W/m²)
- POA Irradiance 5min Average (W/m²)
- Performance Ratio (0-100%, show as gauge)
- Capacity Factor (0-100%, show as gauge)
- Nominal Power (kW)
2. STATIONS SECTION (one section per station with these KPIs):
- Active Power Total (kW)
- Active Power 5min Average (kW)
- Active Energy (kWh)
- Reactive Power Total (VAr)
- POA Irradiance (W/m²)
- POA Irradiance 5min Average (W/m²)
- Performance Ratio (0-100%, show as gauge)
- Nominal Power (kW)
Requirements:
- Real-time updates when tag values change
- Show quality status for each value
- Navigation sidebar to jump between sections
- Professional dark theme
- Accessible via browser on port 8101
Can you provide the complete code with step-by-step setup instructions?Goal:
Create a self-contained dashboard hosted by the N3uron Scripting module that:
- Reads real-time KPI tags from the WebVision PV Demo Project.
- Displays all plant and station KPIs through a modern web interface.
- Automatically updates when tag values change.
- Shows tag quality status for every value.
- Runs locally on port 8101.
| Scripts | Description |
|---|---|
| model | Defines data structures and helper functions for KPIs. |
| ui | Creates the front-end interface (HTML, CSS, and JavaScript). |
| Dashboard Task (Startup) | Subscribes to tag changes and serves live data via HTTP. |
| Dashboard Task (Shutdown) | Cleans up all connections and subscriptions. |
Step 1 — Create Static Library: model
Handles KPI definition, path collection, and JSON payload assembly.
Scripting module instance → Libraries → Add Static Library → Name: model → paste:
// lib: model - absolute paths only
// Exports: buildSectionCards, collectPaths, makePayload
function isNum(x){ return typeof x === "number" && isFinite(x); }
function fmt(val, decimals){ if (!isNum(val)) return "-"; var d = typeof decimals==="number"?decimals:0; return Number(val).toFixed(d); }
function buildSectionCards(getV, getQ, sectionDef){
var out = [];
for (var i=0;i<sectionDef.length;i++){
var c = sectionDef[i] || {};
var val = getV(c.path);
var q = getQ(c.path);
if (c.type === "gauge"){
out.push({ type:"gauge", label:c.label||"", value:isNum(val)?val:null,
min: isNum(c.min)?c.min:0, max: isNum(c.max)?c.max:100,
text: (c.text!=null) ? String(c.text) : (isNum(val) ? (fmt(val,c.decimals)+(c.units?(" "+c.units):"")) : "-"),
quality: q });
} else {
out.push({ type:"number", label:c.label||"", value:isNum(val)?val:null,
display: isNum(val) ? (fmt(val,c.decimals)+(c.units?(" "+c.units):"")) : "-",
quality: q });
}
}
return out;
}
function collectPaths(sections){
var seen={}, list=[];
function addFrom(def){ for (var i=0;i<def.length;i++){ var p=def[i]&&def[i].path; if(p&&!seen[p]){seen[p]=1; list.push(p);} } }
if (sections && sections.general) addFrom(sections.general);
if (sections && sections.stations){ for (var s=0;s<sections.stations.length;s++){ addFrom(sections.stations[s].cards||[]); } }
return list;
}
function makePayload(title, generalDef, stationDefs, getV, getQ){
return {
title: String(title || "KPIs Dashboard"),
ts: Date.now(),
general: buildSectionCards(getV, getQ, generalDef || []),
stations: (stationDefs || []).map(function(st){
return { name: st.name, cards: buildSectionCards(getV, getQ, st.cards || []) };
})
};
}
module.exports = { buildSectionCards:buildSectionCards, collectPaths:collectPaths, makePayload:makePayload };
Step 2 — Create Static Library: ui
Defines the dashboard’s front-end design and dynamic behavior.
Scripting module instance → Libraries → Add Static Library → Name: ui → paste:
// lib: ui - Full HTML + CSS + client-side JS (SSE) + sidebar nav + gauges
function page(title) {
const t = String(title || "KPIs Dashboard");
return `<!doctype html>
<html>
<head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${t}</title>
<style>
:root{ --bg:#0c0f1a; --panel:#0f1529; --panel2:#0b1122; --bd:#1f2a4a;
--fg:#e8edf6; --muted:#98a6c3; --ok:#86efac; --bad:#ffdada;
--okbg:#0d4020; --badbg:#402020; --track:#294273; --value:#69e8f5; --ruler:#F2F2F2;
--sidebar-bg:#0a0e1b; --sidebar-hover:#141b2e; --active:#1e3a5f; --cardH:300px; --gScale:1.18; --gOffsetY:6px;}
html,body{background:linear-gradient(160deg,#0b1020 0%,#0a0d19 100%);color:var(--fg);font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:0;height:100%;overflow:hidden}
.app-container{display:flex;height:100vh}
.sidebar{width:280px;background:var(--sidebar-bg);border-right:1px solid var(--bd);display:flex;flex-direction:column;overflow:hidden}
.sidebar-header{padding:20px 16px;border-bottom:1px solid var(--bd);display:flex;align-items:center;gap:12px}
.logo{width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#67e8f9,#5eead4);box-shadow:0 6px 16px rgba(56,189,248,.35)}
.sidebar-header h1{margin:0;font-size:18px;font-weight:800;letter-spacing:.4px}
.nav-tree{flex:1;overflow-y:auto;padding:12px 0}
.nav-tree::-webkit-scrollbar{width:6px}.nav-tree::-webkit-scrollbar-thumb{background:var(--bd);border-radius:3px}
.nav-section{margin:4px 0}
.nav-section-header{padding:10px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;color:var(--fg);font-size:13px;font-weight:600;letter-spacing:.3px;user-select:none;transition:background .15s}
.nav-section-header:hover{background:var(--sidebar-hover)} .nav-section-header.active{background:var(--active)}
.nav-chevron{width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid var(--muted);transition:transform .2s}
.nav-section.collapsed .nav-chevron{transform:rotate(-90deg)}
.nav-items{max-height:1000px;overflow:hidden;transition:max-height .3s ease-out}
.nav-section.collapsed .nav-items{max-height:0}
.nav-item{padding:8px 16px 8px 40px;cursor:pointer;color:var(--muted);font-size:13px;transition:all .15s;display:flex;align-items:center;gap:8px}
.nav-item:hover{background:var(--sidebar-hover);color:var(--fg)} .nav-item.active{background:var(--active);color:var(--value);font-weight:500}
.nav-dot{width:6px;height:6px;border-radius:50%;background:var(--muted);flex-shrink:0;transition:background .15s}
.nav-item:hover .nav-dot,.nav-item.active .nav-dot{background:var(--value)}
.main-content{flex:1;overflow-y:auto;overflow-x:hidden}
.main-content::-webkit-scrollbar{width:8px}.main-content::-webkit-scrollbar-thumb{background:var(--bd);border-radius:4px}
.container{max-width:1500px;margin:0 auto;padding:28px 18px}
.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}
.links a{color:#9ec5ff;text-decoration:none;margin-left:14px;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid var(--bd);transition:all .15s}
.links a:hover{background:var(--sidebar-hover);border-color:#3a4a70}
.section{margin-bottom:40px;scroll-margin-top:20px}
.section-title{font-size:18px;color:#d6def2;margin:0 0 16px 2px;font-weight:700;letter-spacing:.5px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px;align-items:stretch}
.card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--bd);border-radius:16px;padding:16px;box-shadow:0 10px 24px rgba(0,0,0,.35);height:var(--cardH);display:flex;flex-direction:column;overflow:hidden;transition:transform .2s,box-shadow .2s}
.card:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(0,0,0,.45)}
.label{color:var(--muted);font-size:12px;margin-bottom:8px;display:flex;align-items:center;gap:8px}
.badge{padding:2px 8px;border-radius:999px;font-size:11px;border:1px solid #1f6f32;background:var(--okbg);color:var(--ok)}
.bad{border-color:#7f2d2d;background:var(--badbg);color:var(--bad)}
.val{font-size:40px;font-weight:900;letter-spacing:.5px;display:flex;align-items:center;flex:1 1 auto}
.gwrap{flex:1 1 auto;position:relative;min-height:0;overflow:hidden}
.gwrap .gscale{position:absolute;inset:0;transform:translateY(var(--gOffsetY)) scale(var(--gScale));transform-origin:50% 85%}
.svg{width:100%;height:100%}
.notice{display:none;background:var(--badbg);color:var(--bad);border:1px solid #7f2d2d;padding:12px;border-radius:10px;margin:0 0 20px 0;font-size:13px}
</style>
</head>
<body>
<div class="app-container">
<div class="sidebar"><div class="sidebar-header"><div class="logo"></div><h1>Dashboard</h1></div><nav class="nav-tree" id="navTree"></nav></div>
<div class="main-content" id="mainContent"><div class="container">
<div class="topbar"><h1 style="margin:0;font-size:24px;font-weight:800">${t}</h1><div class="links"><a href="/data" target="_blank">/data</a><a href="/health" target="_blank">/health</a></div></div>
<div id="bn" class="notice"></div><div id="sections"></div>
</div></div>
</div>
<script>
var currentData = null;
function showBanner(msg){ var bn=document.getElementById('bn'); bn.style.display='block'; bn.textContent=String(msg); }
function badge(q){ var ok=(typeof q==='number'&&q>=192); return '<span class="'+(ok?'badge':'badge bad')+'">'+(ok?'Good':'Q='+q)+'</span>'; }
function numCard(c){ return '<div class="card"><div class="label">'+c.label+' '+badge(c.quality)+'</div><div class="val">'+c.display+'</div></div>'; }
function gaugeSVG(value,min,max,label,opts){
var v=(typeof value==='number'&&isFinite(value))?value:null;
var okRange=(typeof min==='number'&&isFinite(min)&&typeof max==='number'&&isFinite(max)&&max>min);
var f=null;if(v!==null&&okRange){ f=(v-min)/(max-min); if(f<0)f=0; if(f>1)f=1; }
var W=300,H=300,cx=200,cy=175,r=100,full=2*Math.PI*r,arc=full*0.75,valLen=(f===null)?0:(arc*f);
var units=(opts&&opts.units)?String(opts.units):'';
return '<svg class="svg" viewBox="0 0 '+W+' '+H+'" preserveAspectRatio="xMidYMid meet"><g transform="translate(-50,0)" shape-rendering="geometricPrecision"><g transform="rotate(135,'+cx+','+cy+')"><circle r="'+r+'" cx="'+cx+'" cy="'+cy+'" fill="#0D0D0D"></circle><circle r="'+r+'" cx="'+cx+'" cy="'+cy+'" stroke="var(--track)" stroke-width="20" stroke-linecap="round" stroke-dasharray="'+arc+', '+full+'" fill="none"></circle><circle r="'+r+'" cx="'+cx+'" cy="'+cy+'" stroke="var(--value)" stroke-width="21.5" stroke-linecap="round" stroke-dasharray="'+valLen+', '+full+'" fill="none"></circle><circle r="'+(r-15)+'" cx="'+cx+'" cy="'+cy+'" stroke="var(--ruler)" stroke-width="2" stroke-dasharray="'+(arc-72)+', '+full+'" fill="none"></circle></g><text x="'+cx+'" y="'+(cy+10)+'" text-anchor="middle" fill="#e9edf3" style="font-family:Inter,system-ui,Segoe UI;font-size:28px;font-weight:800">'+label+'</text>'+(units?('<text x="'+cx+'" y="'+(cy+80)+'" text-anchor="middle" fill="#F2F2F2" style="font-family:Inter,system-ui,Segoe UI;font-size:18px;font-weight:700">'+units+'</text>'):'')+'</g></svg>';
}
function gaugeCard(c){ var label=(c.text!=null? String(c.text):(c.value==null?'-':String(c.value)));
return '<div class="card"><div class="label">'+c.label+' '+badge(c.quality)+'</div><div class="gwrap"><div class="gscale">'+gaugeSVG(c.value,c.min,c.max,label,{units:c.units||""})+'</div></div></div>'; }
function renderSection(id,title,cards){ var html=''; for(var i=0;i<cards.length;i++){ var c=cards[i]; html+= (c.type==='gauge'?gaugeCard(c):numCard(c)); } return '<div class="section" id="'+id+'"><h2 class="section-title">'+title+'</h2><div class="grid">'+html+'</div></div>'; }
function scrollToSection(id){ var el=document.getElementById(id); if(el){ el.scrollIntoView({behavior:'smooth',block:'start'}); var items=document.querySelectorAll('.nav-item'); for(var i=0;i<items.length;i++){items[i].classList.remove('active');} var target=document.querySelector('.nav-item[data-section="'+id+'"]'); if(target){target.classList.add('active');}} }
function toggleSection(h){ var sec=h.parentElement; if(sec.classList.contains('collapsed')){sec.classList.remove('collapsed'); h.classList.add('active');} else {sec.classList.add('collapsed'); h.classList.remove('active');} }
function buildNavigation(data){
var nav=document.createElement('div');
// GENERAL
var gsec=document.createElement('div'); gsec.className='nav-section';
var gh=document.createElement('div'); gh.className='nav-section-header active'; gh.setAttribute('data-target','section-general'); gh.innerHTML='<div class="nav-chevron"></div><span>GENERAL</span>'; gh.onclick=function(){toggleSection(this);};
var gi=document.createElement('div'); gi.className='nav-items';
var gitem=document.createElement('div'); gitem.className='nav-item active'; gitem.setAttribute('data-section','section-general'); gitem.innerHTML='<div class="nav-dot"></div><span>Overview</span>'; gitem.onclick=function(){scrollToSection('section-general');};
gi.appendChild(gitem); gsec.appendChild(gh); gsec.appendChild(gi); nav.appendChild(gsec);
// STATIONS
if(data.stations&&data.stations.length){
var ssec=document.createElement('div'); ssec.className='nav-section';
var sh=document.createElement('div'); sh.className='nav-section-header active'; sh.setAttribute('data-target','section-stations'); sh.innerHTML='<div class="nav-chevron"></div><span>STATIONS</span>'; sh.onclick=function(){toggleSection(this);};
var si=document.createElement('div'); si.className='nav-items';
for(var i=0;i<data.stations.length;i++){ var st=data.stations[i]; var it=document.createElement('div'); it.className='nav-item'; it.setAttribute('data-section','station-'+st.name); it.innerHTML='<div class="nav-dot"></div><span>'+st.name+'</span>'; it.onclick=(function(id){ return function(){scrollToSection(id);} })('station-'+st.name); si.appendChild(it); }
ssec.appendChild(sh); ssec.appendChild(si); nav.appendChild(ssec);
}
var tree=document.getElementById('navTree'); tree.innerHTML=''; tree.appendChild(nav);
}
function renderAll(data){
currentData=data;
var root=document.getElementById('sections'); var html='';
html += renderSection('section-general','GENERAL', data.general||[]);
if(data.stations&&data.stations.length){ for(var i=0;i<data.stations.length;i++){ var st=data.stations[i]; html += renderSection('station-'+st.name, st.name, st.cards||[]); } }
root.innerHTML=html;
buildNavigation(data);
}
// Initial fetch then SSE updates
fetch('/data').then(function(r){return r.json();}).then(function(d){ renderAll(d); }).catch(function(e){ showBanner('Failed to load /data: '+e); });
var es=new EventSource('/events');
es.onmessage=function(ev){ try{ var d=JSON.parse(ev.data); renderAll(d); }catch(ex){ console.error(ex); } };
es.onerror=function(){ showBanner('SSE connection error. Retrying…'); };
</script>
</body></html>`;
}
module.exports = { page };
Step 3 — Create Task: Dashboard (Startup action)
Initializes the dashboard, subscribes to KPI tags using the Scripting API, starts an HTTP server on port 8101, and broadcasts real-time updates to clients via Server-Sent Events (SSE).
Scripting → Model → New Task → Name: Dashboard
Actions → New Action → Type: Startup → Enabled: Yes → paste:
// Dashboard Startup — HTTP + SSE + Tag subscription
// Uses static libs 'model' and 'ui'. Absolute tag paths.
var http = require("http");
var model = $.lib("model");
var ui = $.lib("ui");
// Listen on all interfaces if remote clients will connect:
var HTTP_HOST = "0.0.0.0"; // "127.0.0.1" for local only
var HTTP_PORT = 8101;
// GENERAL (plant-level KPIs) — KPI_VG
var GENERAL = [
{ type:"number", label:"ACTIVE_POWER_TOTAL", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/ACTIVE_POWER_TOTAL", units:"kW", decimals:2 },
{ type:"number", label:"ACTIVE_POWER_5MIN_AVG", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/ACTIVE_POWER_5MIN_AVG", units:"kW", decimals:2 },
{ type:"number", label:"ACTIVE_POWER_HOUR_AVG", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/ACTIVE_POWER_HOUR_AVG", units:"kW", decimals:2 },
{ type:"number", label:"ACTIVE_ENERGY_DAY", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/ACTIVE_ENERGY_DAY", units:"kWh", decimals:2 },
{ type:"number", label:"ACTIVE_ENERGY_TOTAL", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/ACTIVE_ENERGY", units:"kWh", decimals:2 },
{ type:"number", label:"REACTIVE_POWER_TOTAL", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/REACTIVE_POWER_TOTAL", units:"VAr", decimals:2 },
{ type:"number", label:"POA_IRRADIANCE", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/POA_IRRADIANCE", units:"W/m²", decimals:2 },
{ type:"number", label:"POA_IRRADIANCE_5MIN_AVG", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/POA_IRRADIANCE_5MIN_AVG", units:"W/m²", decimals:2 },
{ type:"gauge", label:"PERFORMANCE_RATIO", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/PERFORMANCE_RATIO", units:"%", min:0, max:100, decimals:2 },
{ type:"gauge", label:"CAPACITY_FACTOR", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/CAPACITY_FACTOR", units:"%", min:0, max:100, decimals:2 },
{ type:"number", label:"NOMINAL_POWER", path:"/PVSIM/BLUELAKE/PVG001/KPI_VG/NOMINAL_POWER", units:"kW", decimals:0 }
];
// STATIONS — KPI_ST (PST_01 ... PST_10)
function st(name){ var b="/PVSIM/BLUELAKE/PVG001/"+name+"/KPI_ST/"; return [
{ type:"number", label:"ACTIVE_POWER_TOTAL", path:b+"ACTIVE_POWER_TOTAL", units:"kW", decimals:2 },
{ type:"number", label:"ACTIVE_POWER_5MIN_AVG", path:b+"ACTIVE_POWER_5MIN_AVG", units:"kW", decimals:2 },
{ type:"number", label:"ACTIVE_ENERGY", path:b+"ACTIVE_ENERGY", units:"kWh", decimals:2 },
{ type:"number", label:"REACTIVE_POWER_TOTAL", path:b+"REACTIVE_POWER_TOTAL", units:"VAr", decimals:2 },
{ type:"number", label:"POA_IRRADIANCE", path:b+"POA_IRRADIANCE", units:"W/m²", decimals:2 },
{ type:"number", label:"POA_IRRADIANCE_5MIN_AVG", path:b+"POA_IRRADIANCE_5MIN_AVG", units:"W/m²", decimals:2 },
{ type:"gauge", label:"PERFORMANCE_RATIO", path:b+"PERFORMANCE_RATIO", units:"%", min:0, max:100, decimals:2 },
{ type:"number", label:"NOMINAL_POWER", path:b+"NOMINAL_POWER", units:"kW", decimals:2 }
]; }
var STATIONS = [
{ name:"PST_01", cards: st("PST_01") }, { name:"PST_02", cards: st("PST_02") },
{ name:"PST_03", cards: st("PST_03") }, { name:"PST_04", cards: st("PST_04") },
{ name:"PST_05", cards: st("PST_05") }, { name:"PST_06", cards: st("PST_06") },
{ name:"PST_07", cards: st("PST_07") }, { name:"PST_08", cards: st("PST_08") },
{ name:"PST_09", cards: st("PST_09") }, { name:"PST_10", cards: st("PST_10") }
];
// --- Internal state ---
$.taskLocal.title = "BLUELAKE – PVG001";
$.taskLocal.clients = []; // SSE clients
$.taskLocal.values = {}; // path -> latest numeric/string/boolean
$.taskLocal.qual = {}; // path -> latest quality
$.taskLocal.bump = null; // debounce timer id
$.taskLocal.sub = null; // tag subscription handle
function getV(p){ return $.taskLocal.values.hasOwnProperty(p) ? $.taskLocal.values[p] : null; }
function getQ(p){ return $.taskLocal.qual.hasOwnProperty(p) ? $.taskLocal.qual[p] : 0; }
function scheduleBroadcast(){
if ($.taskLocal.bump) return;
$.taskLocal.bump = setTimeout(function(){
$.taskLocal.bump = null;
var payload = model.makePayload($.taskLocal.title, GENERAL, STATIONS, getV, getQ);
var data = "data: " + JSON.stringify(payload) + "\n\n";
for (var i=$.taskLocal.clients.length-1;i>=0;i--){
var res = $.taskLocal.clients[i];
try { res.write(data); } catch(_){}
}
}, 120); // batch rapid changes
}
function onTagEvent(tag, data, initial){
$.taskLocal.values[tag] = data.value;
$.taskLocal.qual[tag] = data.quality;
if (initial) return; // initial snapshot handled by /data
scheduleBroadcast();
}
// Subscribe to all referenced paths
var ALL = model.collectPaths({ general: GENERAL, stations: STATIONS });
$.logger.info("Dashboard subscribing to %d tags", ALL.length);
$.taskLocal.sub = await $.api.tag.subscribe(ALL, onTagEvent);
// Warm-up read for initial display
try{
for (var i=0;i<ALL.length;i++){
var p = ALL[i];
try{
var r = await $.api.tag.read(p, {});
if (r && r.data){ $.taskLocal.values[p] = r.data.value; $.taskLocal.qual[p] = r.data.quality; }
}catch(ex){ $.logger.warn("Initial read failed for %s: %s", p, ex.message); }
}
}catch(e){ $.logger.warn("Initial bulk read error: %s", e.message); }
// --- HTTP server with UI, /data, /health, /events(SSE) ---
var server = http.createServer(function(req, res){
try{
if (req.url === "/" || req.url === "/index.html"){
res.writeHead(200, {"Content-Type":"text/html; charset=utf-8"});
res.end(ui.page($.taskLocal.title));
return;
}
if (req.url === "/data"){
var payload = model.makePayload($.taskLocal.title, GENERAL, STATIONS, getV, getQ);
res.writeHead(200, {"Content-Type":"application/json"});
res.end(JSON.stringify(payload));
return;
}
if (req.url === "/health"){
var payload = { ok:true, now: Date.now(), counts:{ total: ALL.length }, lastError: "" };
res.writeHead(200, {"Content-Type":"application/json"});
res.end(JSON.stringify(payload));
return;
}
if (req.url === "/events"){
res.writeHead(200, {
"Content-Type":"text/event-stream",
"Cache-Control":"no-cache",
"Connection":"keep-alive",
"Access-Control-Allow-Origin":"*"
});
res.write("retry: 2000\n\n");
$.taskLocal.clients.push(res);
// Send an immediate snapshot so the page paints fast
var payload = model.makePayload($.taskLocal.title, GENERAL, STATIONS, getV, getQ);
res.write("data: " + JSON.stringify(payload) + "\n\n");
req.on("close", function(){
var idx = $.taskLocal.clients.indexOf(res);
if (idx >= 0) $.taskLocal.clients.splice(idx,1);
});
return;
}
// Not found
res.writeHead(404, {"Content-Type":"text/plain; charset=utf-8"});
res.end("Not found");
}catch(ex){
$.logger.warn("HTTP handler error: %s", ex.message);
try{ res.writeHead(500, {"Content-Type":"text/plain"}); res.end("Server error"); }catch(_){}
}
});
server.on("clientError", function(err, socket){
try{ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); }catch(_){}
});
server.listen(HTTP_PORT, HTTP_HOST, function(){
$.logger.info("Dashboard listening on http://%s:%d", HTTP_HOST, HTTP_PORT);
});
$.taskLocal.server = server;
Step 4 — Create Task: Dashboard (Shutdown action)
When the Scripting module shuts down, the Dashboard Shutdown action runs first to clean up resources.
Actions → New Action → Type: Shutdown → Enabled: Yes → paste:
// Dashboard Shutdown — unsubscribe, close SSE clients, stop server
try{ if ($.taskLocal && $.taskLocal.sub){ $.api.tag.unsubscribe($.taskLocal.sub); $.taskLocal.sub=null; } }catch(e){ $.logger.warn("unsubscribe error: %s", e.message); }
try{ if ($.taskLocal && $.taskLocal.clients){ for (var i=$.taskLocal.clients.length-1;i>=0;i--){ var res=$.taskLocal.clients[i]; try{ if(res && !res.writableEnded) res.end(); }catch(_){ } } $.taskLocal.clients=[]; } }catch(_){}
try{ if ($.taskLocal && $.taskLocal.server){ var s=$.taskLocal.server; $.taskLocal.server=null; s.close(); } }catch(e){ $.logger.warn("server close error: %s", e.message); }
try{ if ($.taskLocal && $.taskLocal.bump){ clearTimeout($.taskLocal.bump); $.taskLocal.bump=null; } }catch(_){}
$.logger.info("Dashboard shutdown complete.");
Step 5 — Start it up
Click Save.
The Scripting module will restart.
Open the dashboard in a new browser tab.
Local server: http://localhost:8101
Remote: http://<N3uron-IP>:8101 (make sure HTTP_HOST is set to "0.0.0.0" to listen on all network interfaces and that the firewall allows port 8101).