Guide

Getting started with Industrial UI

Industrial UI is a SCADA and HMI server with a built-in virtual controller, packed into a single program. It talks to your PLCs, meters and microcontrollers, runs control logic written in Ladder or ST, keeps history and alarms, and serves the operator screens to any browser on the network. There is nothing to install on the operator side.

This guide takes you from the download to a working project. Two reference pages go into detail: the Ladder block reference for every function block of the virtual controller, and the HMI widget reference for every screen widget.

Installing

You need a 64-bit Windows PC or a 64-bit ARM computer running Linux — for example a Raspberry Pi 4 or 5 with the 64-bit Raspberry Pi OS — and any modern browser. Download the build for your platform on the Downloads page and put it in a folder of its own: Industrial UI keeps all its data in the folder it is started from — settings, tags, screens, the history database and license.key.

Windows

Run the program. On the first start it asks for administrator rights and registers itself as the Windows service IndustrialUiScada, so from then on it starts together with the computer, even when nobody is logged in. If you decline the prompt, it simply runs in its console window.

Linux

Make the file executable and start it once with sudo from its folder (use the name of the file you downloaded):

chmod +x ./industrial-ui
sudo ./industrial-ui

This installs and starts the systemd service industrial-ui, which starts on boot. You manage it like any other service: systemctl status industrial-ui, restart, stop. Started without sudo, the program runs in the foreground of your terminal.

Then open http://<computer address>:8080 in a browser, or http://localhost:8080 on the same computer.

Installing a new version

Stop the service, replace the program file with the new one, keeping the same name and folder, and start the service again. Your project files stay where they are.

Activating the license

Until it finds a valid license, Industrial UI starts only a small activation page.

  1. Open the page and copy the Hardware Code (HWID), 64 characters long.
  2. Sign in here with your email, open your account and request a free trial — 20 connections for 90 days — or buy a license for that HWID.
  3. When the license is signed (you get an email as well), download license.key from your account.
  4. Upload it on the activation page. Industrial UI checks the file and starts. Copying license.key into the program folder and restarting works too.

A few things worth knowing before you choose the machine:

The editor password

Screens open in RUN MODE, the operator view: live values and working controls, nothing can be moved. Everything else — devices, tags, screens, programs — is changed in the editor. Click Enter Editor and type the editor password; you can change it later with Change Password in the toolbox.

There is no default password. The first time you click Enter Editor, Industrial UI asks you to create the editor password, at least 6 characters long, and opens the editor only after that. Do it right after installation: until a password is set, whoever opens the editor first chooses it. The editor password protects everything — the project, the values written to your devices and the Terminal widget, which opens a command shell on the SCADA computer.

Installations updated from an earlier version keep their password. If yours still uses the old default 123, change it now with Change Password.

After five wrong passwords in a row, signing in is blocked for 30 seconds. Two more layers control who sees what:

The server listens on every network interface on port 8080. Keep it inside the plant network or behind a VPN and do not publish it to the internet. To accept connections only from the computer itself, set "web_host": "127.0.0.1" in config.json.

Connecting devices

In the editor, open the device list and add a device: choose the protocol, enter the connection details and the poll interval. A device can be switched off without deleting it, and when a connection is lost, Industrial UI raises an alarm by itself.

ProtocolTypical useTag address
Modbus TCPPLCs, energy meters, drives, remote I/O, ESP32 over Wi-Fi10, 10.3
Siemens S7S7-1200, S7-1500, S7-300 and S7-400 PLCsDB1.DBW10, M10.2
MQTT, MQTT (TLS/SSL)IoT sensors, gateways, home automationThe topic
Serial PortESP32, Arduino and other boards over USB or RS-2320, 0.3
Web API (HTTP/JSON)Weather, exchange rates, energy prices, other systemsmain.temp
SoftPLC (Internal)The virtual controllerTags of the program

Modbus TCP

Enter the IP address, the port (usually 502) and the poll interval. Industrial UI groups neighbouring registers into block reads, so a long tag list does not mean a long poll.

A complete ESP32 sketch that works as a Modbus TCP I/O node over Wi-Fi is in the ESP32 example below.

Siemens S7

Enter the IP address, rack and slot — for S7-1200 and S7-1500 usually rack 0, slot 1; for S7-300 rack 0, slot 2; for S7-400 rack 0 and the slot of the CPU in the rack, as set in the hardware configuration. Addresses use the absolute S7 notation: DB1.DBX0.3 (bit), DB1.DBW10 (word), DB1.DBD4 (double word or REAL), markers M10.2. On S7-1200 and S7-1500, allow PUT/GET communication in the CPU protection settings and turn off “Optimized block access” for the data blocks you read, otherwise the absolute addresses are not reachable.

MQTT

Enter the broker address, and a user name and password if the broker needs them; choose MQTT (TLS/SSL) for an encrypted connection. The address of a tag is its topic. A payload may be a number or true/false, on/off, 1/0. When it connects, Industrial UI publishes <topic>/get so that devices can answer with their current value; writes from the screens are published to the tag's own topic as retained messages.

Serial port

Choose the port (COM3, /dev/ttyUSB0) and the baud rate. The protocol is plain text, so it takes a few lines of code on a microcontroller:

A complete ESP32 sketch for this protocol is in the ESP32 example below.

Example: ESP32 as an I/O node

Two ready-to-use Arduino sketches turn an ESP32 board into a small remote I/O module: analogue inputs, digital inputs and outputs, PWM outputs and an I2C sensor. One talks Modbus TCP over Wi-Fi, the other the serial protocol over USB. Both use the same register map, so the same tags work with either of them.

RegisterContentAccess
0–3Analogue inputs, 0–4095, smoothedread
4–5Two 16-bit words from an I2C device at address 0x40read
6Digital inputs, one bit per inputread
7–14PWM outputs, 0–1023 (the USB sketch uses 7–11)write
15Digital outputs, one bit per output. Wi-Fi sketch: bit 0 gives a 250 ms pulse, bit 8 drives the on-board LEDwrite

Tags for this board in Industrial UI could look like this:

TagType and accessAddressWidget
tank_levelINT16, read-only0Level Bar, 0–4095
door_closedBOOL, read-only6.1Lamp
fan_speedINT16, read-write7Slider, 0–1023
pumpBOOL, read-write15.2Toggle Switch

Over Wi-Fi: Modbus TCP — LOLIN S2 Mini (ESP32-S2)

  1. In the Arduino IDE install the ESP32 core 3.x and, in the Library Manager, modbus-esp8266 by Alexander Emelianov and WiFiManager by tzapu. Choose the board LOLIN S2 Mini and set USB CDC On Boot to Enabled.
  2. Upload the sketch. On the first start the board opens the Wi-Fi access point ESP_Modbus_Setup: connect to it, open http://192.168.4.1, choose your network and give the board a static IP address.
  3. In Industrial UI add a Modbus TCP device with that address, port 502 and a poll interval of 200–1000 ms. If nobody polls the board for 8 seconds, it switches all outputs off.
Show the full sketch: esp32-modbus-tcp.ino
/*
 * Industrial UI — ESP32 I/O node over Wi-Fi (Modbus TCP)
 * Board: LOLIN S2 Mini (ESP32-S2), Arduino core for ESP32 3.x
 *
 * Exposes 16 holding registers over Modbus TCP, port 502. In Industrial UI add
 * the board as a "Modbus TCP" device with its IP address. Tag addresses are
 * register numbers ("0".."15") or bits of a register ("6.0", "15.3").
 *
 * Register map (shared with the serial example):
 *   0..3    ADC inputs, EMA-filtered, 0..4095                  read
 *   4..5    two 16-bit words from an I2C device at 0x40        read
 *   6       digital inputs: bits 0..2 = DI0..DI2               read
 *   7..14   PWM outputs, 0..1023                               write
 *   15      digital outputs:                                   write
 *             bit 0      pulse output DO0: 1 starts a 250 ms pulse, then clears itself
 *             bits 1..7  outputs DO1..DO7
 *             bit 8      on-board LED
 *
 * Fail-safe: when no Modbus request arrives for 8 s, all outputs switch off.
 *
 * Wi-Fi setup: if the saved network is not available, the board opens the
 * access point "ESP_Modbus_Setup". Connect to it, open http://192.168.4.1 and
 * choose the network and a static IP address; the board then restarts.
 *
 * Status LED (two-colour LED, anti-parallel between pins 13 and 14):
 *   red, blinking   connecting to Wi-Fi or setup portal running
 *   red             connected, no Modbus master polling
 *   green flash     Modbus request received
 *
 * Arduino IDE: board "LOLIN S2 Mini", "USB CDC On Boot: Enabled".
 * Libraries: modbus-esp8266 (Alexander Emelianov), WiFiManager (tzapu).
 * Upload: if the board is not detected, hold button 0, press RST, release button 0.
 */

#include <WiFi.h>
#include <Wire.h>
#include <Preferences.h>
#include <WiFiManager.h>
#include <ModbusIP_ESP8266.h>

// ----------------------------------------------------------------------------
// Register map
// ----------------------------------------------------------------------------
const int REG_COUNT = 16;
const int REG_ADC   = 0;                // 0..3
const int REG_I2C   = 4;                // 4..5
const int REG_DI    = 6;
const int REG_PWM   = 7;                // 7..14
const int REG_DO    = 15;

const int PULSE_BIT       = 0;          // REG_DO bit that drives DO0 as a pulse
const int LED_BUILTIN_BIT = 8;          // REG_DO bit for the on-board LED

// ----------------------------------------------------------------------------
// Pins (LOLIN S2 Mini)
// ----------------------------------------------------------------------------
// Avoid GPIO 0, 45 and 46 as outputs (strapping pins), 19 and 20 (USB) and
// 15 (on-board LED). GPIO 0 is the BOOT button: it is safe to read after boot,
// but do not hold it during power-on or reset, or the board enters the bootloader.
const int NUM_ADC = 4;
const int ADC_PINS[NUM_ADC] = {3, 5, 7, 9};         // ADC1, usable with Wi-Fi on

const int NUM_PWM = 8;
const int PWM_PINS[NUM_PWM] = {18, 11, 12, 1, 2, 4, 6, 8};

const int NUM_DI = 3;
const int DI_PINS[NUM_DI] = {37, 39, 0};            // DI2 = BOOT button
const bool DI_ACTIVE_LOW = true;                    // true: pull-up, active when shorted to GND

const int NUM_DO = 8;
const int DO_PINS[NUM_DO] = {16, 10, 17, 21, 34, 36, 38, 40};
// Polarity per output: true = active-LOW (typical relay module), false = active-HIGH.
const bool DO_ACTIVE_LOW[NUM_DO] = {true, true, true, true, true, true, true, true};

const int I2C_SDA_PIN     = 33;
const int I2C_SCL_PIN     = 35;
const int I2C_DEVICE_ADDR = 0x40;

const int LED_GREEN_PIN   = 13;         // two-colour status LED, anti-parallel
const int LED_RED_PIN     = 14;
const int LED_BUILTIN_PIN = 15;         // on-board blue LED, lit when HIGH

// ----------------------------------------------------------------------------
// Network
// ----------------------------------------------------------------------------
const char *SETUP_AP_NAME = "ESP_Modbus_Setup";
const char *DEFAULT_IP    = "192.168.1.50";     // static address suggested in the setup portal
const char *DEFAULT_GW    = "192.168.1.1";
const char *DEFAULT_MASK  = "255.255.255.0";
// S2 Mini and C3 Mini often fail to connect at full transmit power
// (supply sag, small antenna); 8.5 dBm is a reliable value.
const wifi_power_t WIFI_TX_POWER = WIFI_POWER_8_5dBm;

// ----------------------------------------------------------------------------
// Timing and filtering
// ----------------------------------------------------------------------------
const unsigned long MASTER_TIMEOUT_MS = 8000;   // fail-safe: outputs off without polling
const unsigned long WIFI_CHECK_MS     = 10000;
const unsigned long I2C_POLL_MS       = 100;
const unsigned long PULSE_MS          = 250;
const unsigned long GREEN_FLASH_MS    = 80;
const float EMA_ALPHA = 0.02;                   // ADC smoothing: smaller = smoother

// ----------------------------------------------------------------------------
// State
// ----------------------------------------------------------------------------
ModbusIP mb;
Preferences prefs;
bool configSaved = false;

float filteredAdc[NUM_ADC] = {0};
unsigned long lastWifiCheck = 0;
unsigned long lastI2cPoll = 0;
unsigned long lastRequest = 0;                  // time of the last Modbus request
bool inModbusTask = false;
bool requestReceived = false;
unsigned long greenFlashUntil = 0;
bool pulseActive = false;
unsigned long pulseUntil = 0;

// ============================================================================
// Status LED
// ============================================================================

void ledRed()   { digitalWrite(LED_GREEN_PIN, HIGH); digitalWrite(LED_RED_PIN, LOW);  }
void ledGreen() { digitalWrite(LED_GREEN_PIN, LOW);  digitalWrite(LED_RED_PIN, HIGH); }
void ledOff()   { digitalWrite(LED_GREEN_PIN, LOW);  digitalWrite(LED_RED_PIN, LOW);  }

// A hardware timer blinks red while WiFiManager blocks the main loop.
hw_timer_t *blinkTimer = NULL;
volatile bool blinkOn = false;
volatile uint32_t blinkTicks = 0;               // one tick = 100 ms

void IRAM_ATTR onBlinkTimer() {
  blinkTicks = blinkTicks + 1;
  if (blinkTicks % 4 == 0) {                    // toggle every 400 ms
    blinkOn = !blinkOn;
    digitalWrite(LED_GREEN_PIN, blinkOn ? HIGH : LOW);
    digitalWrite(LED_RED_PIN, LOW);
  }
}

void startRedBlink() {
  blinkTimer = timerBegin(1000000);             // 1 MHz
  timerAttachInterrupt(blinkTimer, &onBlinkTimer);
  timerAlarm(blinkTimer, 100000, true, 0);      // 100 ms, auto-reload
}

void stopRedBlink() {
  if (blinkTimer) {
    timerDetachInterrupt(blinkTimer);
    timerEnd(blinkTimer);
    blinkTimer = NULL;
  }
}

// ============================================================================
// Outputs
// ============================================================================

void setDigitalOutput(int index, bool on) {
  digitalWrite(DO_PINS[index], (on != DO_ACTIVE_LOW[index]) ? HIGH : LOW);
}

void writePwm(int channel, uint16_t value) {
  value = constrain((int)value, 0, 1023);
  analogWrite(PWM_PINS[channel], map(value, 0, 1023, 0, 255));
}

// DO0 is a pulse output: the bit starts a pulse and is cleared when it ends.
void startPulse() {
  setDigitalOutput(0, true);
  pulseActive = true;
  pulseUntil = millis() + PULSE_MS;
}

void servicePulse() {
  if (pulseActive && (long)(millis() - pulseUntil) >= 0) {
    setDigitalOutput(0, false);
    pulseActive = false;
    uint16_t packed = mb.Hreg(REG_DO);
    bitClear(packed, PULSE_BIT);
    mb.Hreg(REG_DO, packed);
  }
}

void writeOutputs() {
  for (int i = 0; i < NUM_PWM; i++) writePwm(i, mb.Hreg(REG_PWM + i));

  uint16_t packed = mb.Hreg(REG_DO);
  if (bitRead(packed, PULSE_BIT) && !pulseActive) startPulse();
  for (int i = 1; i < NUM_DO; i++) setDigitalOutput(i, bitRead(packed, i));
  digitalWrite(LED_BUILTIN_PIN, bitRead(packed, LED_BUILTIN_BIT) ? HIGH : LOW);
}

// Fail-safe state: everything off.
void switchOutputsOff() {
  for (int i = 0; i < NUM_PWM; i++) analogWrite(PWM_PINS[i], 0);
  pulseActive = false;
  for (int i = 0; i < NUM_DO; i++) setDigitalOutput(i, false);
  digitalWrite(LED_BUILTIN_PIN, LOW);
}

// ============================================================================
// Inputs
// ============================================================================

bool inputActive(int pin) {
  return (digitalRead(pin) == LOW) == DI_ACTIVE_LOW;
}

void readInputs() {
  for (int i = 0; i < NUM_ADC; i++) {
    filteredAdc[i] = EMA_ALPHA * analogRead(ADC_PINS[i]) + (1.0 - EMA_ALPHA) * filteredAdc[i];
    mb.Hreg(REG_ADC + i, (uint16_t)filteredAdc[i]);
  }

  unsigned long now = millis();
  if (now - lastI2cPoll >= I2C_POLL_MS) {
    lastI2cPoll = now;
    Wire.requestFrom((uint16_t)I2C_DEVICE_ADDR, (size_t)4);
    if (Wire.available() == 4) {
      uint8_t b[4];
      for (int i = 0; i < 4; i++) b[i] = Wire.read();   // high byte first
      mb.Hreg(REG_I2C, (b[0] << 8) | b[1]);
      mb.Hreg(REG_I2C + 1, (b[2] << 8) | b[3]);
    }
  }

  uint16_t di = 0;
  for (int i = 0; i < NUM_DI; i++) {
    if (inputActive(DI_PINS[i])) bitSet(di, i);
  }
  mb.Hreg(REG_DI, di);
}

// ============================================================================
// Modbus and Wi-Fi
// ============================================================================

// Called for every register a request touches: marks the master as alive.
uint16_t onRegisterAccess(TRegister *reg, uint16_t value) {
  if (inModbusTask) {
    lastRequest = millis();
    requestReceived = true;
  }
  return value;
}

void onWifiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
  switch (event) {
    case ARDUINO_EVENT_WIFI_STA_CONNECTED:
      Serial.println("[WIFI] Associated with the access point");
      break;
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
      Serial.printf("[WIFI] IP address: %s\n", WiFi.localIP().toString().c_str());
      break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
      Serial.printf("[WIFI] Disconnected, reason %d\n", info.wifi_sta_disconnected.reason);
      break;
    default:
      break;
  }
}

void onPortalStarted(WiFiManager *manager) {
  WiFi.setTxPower(WIFI_TX_POWER);               // WiFiManager switched the mode: apply again
  Serial.printf("[WIFI] Setup portal started: connect to \"%s\", open http://192.168.4.1\n", SETUP_AP_NAME);
}

void onConfigSaved() {
  configSaved = true;
}

const char *PORTAL_CSS =
  "<style>"
  "body{background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#333}"
  ".wrap{max-width:400px;margin:40px auto;background:#fff;padding:30px 25px;border-radius:16px;box-shadow:0 10px 25px rgba(0,0,0,.08)}"
  "h1{font-size:24px;color:#1a1a1a;margin-bottom:20px;font-weight:600;text-align:center}"
  "button{width:100%;background:#007bff;color:#fff;padding:14px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;margin-bottom:15px}"
  "button:hover{background:#0056b3}"
  "input[type='text'],input[type='password'],input[type='number']{width:100%;padding:12px 15px;margin:8px 0 20px;border:1px solid #d1d5db;border-radius:8px;box-sizing:border-box;font-size:16px;background:#f9fafb}"
  "input:focus{border-color:#007bff;outline:none;background:#fff}"
  "label{font-size:14px;font-weight:500;color:#4b5563;display:block;margin-bottom:-4px}"
  "</style>";

void connectWifi() {
  prefs.begin("wifi_cfg", true);
  String ipText   = prefs.getString("ip", DEFAULT_IP);
  String gwText   = prefs.getString("gw", DEFAULT_GW);
  String maskText = prefs.getString("sn", DEFAULT_MASK);
  prefs.end();
  Serial.printf("[WIFI] Static IP %s, gateway %s, mask %s\n", ipText.c_str(), gwText.c_str(), maskText.c_str());

  IPAddress ip, gateway, mask;
  ip.fromString(ipText);
  gateway.fromString(gwText);
  mask.fromString(maskText);

  WiFi.onEvent(onWifiEvent);
  WiFi.mode(WIFI_STA);
  WiFi.setTxPower(WIFI_TX_POWER);
  WiFi.disconnect();
  delay(100);

  WiFiManager manager;
  manager.setCustomHeadElement(PORTAL_CSS);
  manager.setSaveConfigCallback(onConfigSaved);
  manager.setAPCallback(onPortalStarted);
  manager.setDebugOutput(true);
  manager.setConnectTimeout(20);                // seconds per connection attempt
  manager.setConfigPortalTimeout(300);          // portal open for 5 minutes, then restart
  manager.setSTAStaticIPConfig(ip, gateway, mask);

  Serial.println("[WIFI] Connecting...");
  startRedBlink();
  if (!manager.autoConnect(SETUP_AP_NAME)) {
    stopRedBlink();
    Serial.println("[WIFI] Connection failed, restarting");
    delay(3000);
    ESP.restart();
  }
  stopRedBlink();

  // New settings from the portal: store the address and restart to apply it.
  if (configSaved) {
    prefs.begin("wifi_cfg", false);
    prefs.putString("ip", WiFi.localIP().toString());
    prefs.putString("gw", WiFi.gatewayIP().toString());
    prefs.putString("sn", WiFi.subnetMask().toString());
    prefs.end();
    Serial.println("[WIFI] Settings saved, restarting");
    delay(500);
    ESP.restart();
  }

  Serial.printf("[WIFI] Connected: IP %s, gateway %s, mask %s\n", WiFi.localIP().toString().c_str(),
                WiFi.gatewayIP().toString().c_str(), WiFi.subnetMask().toString().c_str());
}

// ============================================================================

void setup() {
  // The S2 Mini prints over native USB: wait up to 3 s for a terminal so the
  // start-up messages are not lost; without a computer just continue.
  Serial.begin(115200);
  unsigned long start = millis();
  while (!Serial && millis() - start < 3000) delay(10);
  Serial.println("\n[SYSTEM] Industrial UI Modbus TCP node, LOLIN S2 Mini");

  pinMode(LED_GREEN_PIN, OUTPUT);
  pinMode(LED_RED_PIN, OUTPUT);
  pinMode(LED_BUILTIN_PIN, OUTPUT);
  digitalWrite(LED_BUILTIN_PIN, LOW);
  ledRed();

  uint8_t inputMode = DI_ACTIVE_LOW ? INPUT_PULLUP : INPUT_PULLDOWN;
  for (int i = 0; i < NUM_DI; i++) pinMode(DI_PINS[i], inputMode);

  // Set the inactive level before enabling the outputs, so relays do not click at start.
  for (int i = 0; i < NUM_DO; i++) {
    digitalWrite(DO_PINS[i], DO_ACTIVE_LOW[i] ? HIGH : LOW);
    pinMode(DO_PINS[i], OUTPUT);
    setDigitalOutput(i, false);
  }
  for (int i = 0; i < NUM_PWM; i++) {
    pinMode(PWM_PINS[i], OUTPUT);
    analogWrite(PWM_PINS[i], 0);
  }

  // The ESP32-S2 ADC is 13-bit by default; use 12 bits (0..4095) like the classic ESP32.
  analogReadResolution(12);
  analogSetAttenuation(ADC_11db);

  connectWifi();
  ledRed();                                     // connected, waiting for the master

  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  for (int i = 0; i < NUM_ADC; i++) filteredAdc[i] = analogRead(ADC_PINS[i]);

  mb.server();
  mb.addHreg(0, 0, REG_COUNT);
  for (int i = 0; i < REG_COUNT; i++) {
    mb.onGetHreg(i, onRegisterAccess);
    mb.onSetHreg(i, onRegisterAccess);
  }
  Serial.println("[MODBUS] Server started on port 502");
}

void loop() {
  inModbusTask = true;
  mb.task();
  inModbusTask = false;

  unsigned long now = millis();
  servicePulse();

  if (now - lastWifiCheck >= WIFI_CHECK_MS) {
    lastWifiCheck = now;
    if (WiFi.status() != WL_CONNECTED) {
      Serial.println("[WIFI] Connection lost, reconnecting");
      WiFi.disconnect();
      WiFi.reconnect();
    }
  }

  readInputs();

  bool masterActive = lastRequest != 0 && now - lastRequest <= MASTER_TIMEOUT_MS;
  if (!masterActive) {
    ledRed();
    switchOutputsOff();
    return;
  }

  if (requestReceived) {
    requestReceived = false;
    greenFlashUntil = now + GREEN_FLASH_MS;
    ledGreen();
  } else if ((long)(now - greenFlashUntil) >= 0) {
    ledOff();
  }
  writeOutputs();
}

Over USB: serial — ESP32 DevKit (ESP32-WROOM-32)

  1. Choose the board ESP32 Dev Module; no extra libraries are needed. Upload the sketch.
  2. In Industrial UI add a Serial Port device with the board's port, 115200 baud and a poll interval of 100 ms.
  3. GPIO12, used here for one output, must stay low while the board starts: do not connect a load that pulls it high at power-on.
Show the full sketch: esp32-serial.ino
/*
 * Industrial UI — ESP32 I/O node over USB serial
 * Board: ESP32 DevKit (ESP32-WROOM-32), Arduino core for ESP32 3.x
 *
 * Streams 16 registers as one CSV line over the USB serial port and accepts
 * write commands from Industrial UI. In Industrial UI add the board as a
 * "Serial Port" device (COM3, /dev/ttyUSB0, ...) with the baud rate below.
 *
 * Protocol (Industrial UI "Serial Port" driver):
 *   board -> Industrial UI   "<reg0>,<reg1>,...,<reg15>\n", as often as the port allows
 *   Industrial UI -> board   "<address>=<value>\n", for example "7=512" or "15.2=1"
 *   Tag addresses            "0".."15" for a register, "6.3" for bit 3 of register 6
 *
 * Register map (shared with the Modbus TCP example):
 *   0..3    ADC inputs, EMA-filtered, 0..4095                  read
 *   4..5    two 16-bit words from an I2C device at 0x40        read
 *   6       digital inputs: bits 0..2 = DI0..DI2, bit 3 = button   read
 *   7..11   PWM outputs, 0..1023 (12..14 reserved)             write
 *   15      digital outputs: bits 0..3 = DO0..DO3              write
 * Only the output registers accept writes; writes to inputs are ignored.
 *
 * Note: there is no communication timeout on this link. Industrial UI sends a
 * command only when a value changes, so silence does not mean the host is gone.
 */

#include <Wire.h>

const long BAUD_RATE = 115200;          // must match the device settings in Industrial UI

// ----------------------------------------------------------------------------
// Register map
// ----------------------------------------------------------------------------
const int REG_COUNT = 16;
const int REG_ADC   = 0;                // 0..3
const int REG_I2C   = 4;                // 4..5
const int REG_DI    = 6;
const int REG_PWM   = 7;                // 7..11, 12..14 reserved
const int REG_DO    = 15;

// ----------------------------------------------------------------------------
// Pins
// ----------------------------------------------------------------------------
// Strapping pins 0, 2, 12 and 15, flash pins 6..11 and UART0 pins 1 and 3 are
// best avoided. GPIO12 is used for DO3 and GPIO15 for the button below: GPIO12
// must stay LOW while the board boots (a HIGH level selects 1.8 V flash and the
// module does not start), so do not connect a load that pulls it up at power-on,
// or move DO3 to a free pin.
const int NUM_ADC = 4;
const int ADC_PINS[NUM_ADC] = {34, 35, 36, 39};    // input-only, ADC1

const int NUM_PWM = 5;
const int PWM_PINS[NUM_PWM] = {18, 32, 33, 26, 13};

const int NUM_DI = 3;
const int DI_PINS[NUM_DI] = {16, 17, 21};
const int BUTTON_PIN = 15;                          // reported as bit NUM_DI of REG_DI
const bool DI_ACTIVE_LOW = true;                    // true: pull-up, active when shorted to GND

const int NUM_DO = 4;
const int DO_PINS[NUM_DO] = {25, 22, 27, 12};
// Polarity per output: true = active-LOW (typical relay module), false = active-HIGH.
const bool DO_ACTIVE_LOW[NUM_DO] = {false, true, true, true};

const int I2C_SDA_PIN     = 19;
const int I2C_SCL_PIN     = 23;
const int I2C_DEVICE_ADDR = 0x40;

// ----------------------------------------------------------------------------
// Timing and filtering
// ----------------------------------------------------------------------------
const unsigned long I2C_POLL_MS = 100;
const float EMA_ALPHA = 0.02;           // ADC smoothing: smaller = smoother
const int TX_LINE_MAX = 100;            // longest CSV line: 16 x "65535" + commas + newline
const int RX_LINE_MAX = 64;             // longer command lines are dropped

// ----------------------------------------------------------------------------
// State
// ----------------------------------------------------------------------------
uint16_t regs[REG_COUNT] = {0};
float filteredAdc[NUM_ADC] = {0};
unsigned long lastI2cPoll = 0;
String rxLine;

// ============================================================================

bool inputActive(int pin) {
  return (digitalRead(pin) == LOW) == DI_ACTIVE_LOW;
}

void writeDigitalOutputs(uint16_t packed) {
  for (int i = 0; i < NUM_DO; i++) {
    bool on = bitRead(packed, i);
    digitalWrite(DO_PINS[i], (on != DO_ACTIVE_LOW[i]) ? HIGH : LOW);
  }
}

void writePwm(int channel, uint16_t value) {
  value = constrain((int)value, 0, 1023);
  analogWrite(PWM_PINS[channel], map(value, 0, 1023, 0, 255));
}

// Refresh the input registers: ADC, I2C and digital inputs.
void readInputs() {
  for (int i = 0; i < NUM_ADC; i++) {
    filteredAdc[i] = EMA_ALPHA * analogRead(ADC_PINS[i]) + (1.0 - EMA_ALPHA) * filteredAdc[i];
    regs[REG_ADC + i] = (uint16_t)filteredAdc[i];
  }

  unsigned long now = millis();
  if (now - lastI2cPoll >= I2C_POLL_MS) {
    lastI2cPoll = now;
    Wire.requestFrom((uint16_t)I2C_DEVICE_ADDR, (size_t)4);
    if (Wire.available() == 4) {
      uint8_t b[4];
      for (int i = 0; i < 4; i++) b[i] = Wire.read();   // high byte first
      regs[REG_I2C]     = (b[0] << 8) | b[1];
      regs[REG_I2C + 1] = (b[2] << 8) | b[3];
    }
  }

  uint16_t di = 0;
  for (int i = 0; i < NUM_DI; i++) {
    if (inputActive(DI_PINS[i])) bitSet(di, i);
  }
  if (inputActive(BUTTON_PIN)) bitSet(di, NUM_DI);
  regs[REG_DI] = di;
}

// Write a register coming from Industrial UI. Only output registers are writable.
void writeRegister(int index, uint16_t value) {
  if (index >= REG_PWM && index < REG_PWM + NUM_PWM) {
    regs[index] = value;
    writePwm(index - REG_PWM, value);
  } else if (index == REG_DO) {
    regs[index] = value;
    writeDigitalOutputs(value);
  }
}

// Parse "<index>=<value>" or "<index>.<bit>=<value>".
void handleCommand(const String &line) {
  int eq = line.indexOf('=');
  if (eq < 0) return;
  String address = line.substring(0, eq);
  long value = line.substring(eq + 1).toInt();

  int dot = address.indexOf('.');
  if (dot < 0) {
    writeRegister(address.toInt(), (uint16_t)value);
    return;
  }
  int index = address.substring(0, dot).toInt();
  int bit = address.substring(dot + 1).toInt();
  if (index < 0 || index >= REG_COUNT || bit < 0 || bit > 15) return;
  uint16_t word = regs[index];
  if (value != 0) bitSet(word, bit); else bitClear(word, bit);
  writeRegister(index, word);
}

void readCommands() {
  while (Serial.available() > 0) {
    char c = (char)Serial.read();
    if (c == '\n') {
      rxLine.trim();
      if (rxLine.length() > 0) handleCommand(rxLine);
      rxLine = "";
    } else if (c != '\r') {
      rxLine += c;
      if (rxLine.length() > RX_LINE_MAX) rxLine = "";
    }
  }
}

void sendRegisters() {
  String line;
  line.reserve(TX_LINE_MAX);
  for (int i = 0; i < REG_COUNT; i++) {
    if (i > 0) line += ',';
    line += String(regs[i]);
  }
  Serial.println(line);
}

void setup() {
  Serial.begin(BAUD_RATE);
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  analogReadResolution(12);

  uint8_t inputMode = DI_ACTIVE_LOW ? INPUT_PULLUP : INPUT_PULLDOWN;
  for (int i = 0; i < NUM_DI; i++) pinMode(DI_PINS[i], inputMode);
  pinMode(BUTTON_PIN, inputMode);

  // Set the inactive level before enabling the outputs, so relays do not click at start.
  for (int i = 0; i < NUM_DO; i++) {
    digitalWrite(DO_PINS[i], DO_ACTIVE_LOW[i] ? HIGH : LOW);
    pinMode(DO_PINS[i], OUTPUT);
  }
  writeDigitalOutputs(0);

  for (int i = 0; i < NUM_PWM; i++) {
    pinMode(PWM_PINS[i], OUTPUT);
    analogWrite(PWM_PINS[i], 0);
  }

  for (int i = 0; i < NUM_ADC; i++) filteredAdc[i] = analogRead(ADC_PINS[i]);
}

void loop() {
  readCommands();

  // Send as often as the port allows, but only when a whole line fits into the
  // UART buffer: the loop never blocks and the line rate follows the baud rate.
  if (Serial.availableForWrite() >= TX_LINE_MAX) {
    readInputs();
    sendRegisters();
  }
}

Web API

Paste the full URL, including an API key if the service needs one; set a user name and password only for HTTP Basic authentication. The address of a tag is a path in the JSON answer: main.temp, rates.USD, list[0].main.temp. Sources are polled at most once per second — most free services block clients that ask more often — and are read-only.

Tags

A tag is a named value: the temperature of a tank, the state of a pump, a setpoint. It belongs to a device and has an address, a type — BOOL, INT16 or FLOAT32 — and access, read-only or read-write. You can also set the number of decimals shown on screens and mark the tag as an alarm.

Create tags one at a time with Create Tag, or prepare a list in a spreadsheet and load it with Import CSV; selected tags export to CSV the same way. If you draw the cabinet in Industrial UI Schematic, it exports the tags of its PLC channels as such a file.

Some tags exist in every project:

HMI screens

Screens are the tabs along the top; in the editor, + adds one. Pick a widget in the Toolbox, place it and Bind it to a tag in the properties panel. Everything is saved as you go, and a widget whose value stopped updating is marked on the screen, so stale data never looks live. Ctrl+Z and Ctrl+Y undo and redo, Delete removes the selected widgets and Duplicate copies them — useful when a screen has ten identical pumps.

GroupWidgets
Values and inputDisplay for a plain value; Display+ with a unit and warning and alarm colours; Text and Multi-Text for labels and state texts; Input for setpoints; Dropdown for modes; PIN for a masked code of up to 4 digits
ControlsSwitch and Toggle Switch for on/off, Push Button for momentary commands, Hotkey Button for keyboard shortcuts, Slider
IndicatorsLamp, LED Indicator, Halo Indicator, Warning Triangle, Level Bar for tanks, Gauge, V-Scale
TrendsLive Chart for values as they change, History Chart for the stored history
Process graphicsMotor, Valve, Pipe and Joint for mimic diagrams, Image, State Image that changes with a value, IP Camera
SystemAlarm Log, SoftPLC Control, Terminal

Each widget, its settings and typical uses are described in the HMI widget reference.

The virtual controller

The virtual controller — SoftPLC — runs control logic inside Industrial UI itself, without a hardware PLC. A heating loop, pump sequencing, lighting on sunset or a batch counter can live right next to the screens that show them, and it can read and write the tags of every connected device: Modbus, S7, MQTT and serial alike.

Add a device with the protocol SoftPLC (Internal) and choose the language, Ladder Diagram (LD) or ST. The poll interval of that device is the scan cycle: every cycle the program reads its inputs, runs once and writes its outputs. A project can have several controllers with different cycles, for example a fast one for interlocks and a slow one for schedules. If a scan takes longer than its cycle, the tag sys_scan_overrun_<device> turns on.

Ladder Diagram

A Ladder program is a list of rungs. Each rung has one or more parallel branches — an OR — and each branch is a chain of contacts and function blocks — an AND. A branch can drive its own coils, and all branches together feed a common tail with more blocks and coils.

Rungs can have comments and can be switched off for commissioning: a disabled rung is skipped and its outputs keep their last values. A rung can also carry a short ST script that runs while the rung is powered. The ⌕ button shows where a tag is used across the program.

Start and stop a motor with two push buttons:

  start_btn               motor
──┤ ├─────────────────────(S)──

  stop_btn                motor
──┤ ├─────────────────────(R)──

ST scripts

ST programs are scripts run once per scan. READ("tag") reads a tag, WRITE("tag", value) writes one, and LOG("text") writes to the server log. Anything kept in STATE survives from one scan to the next, which is where timers and counters live; STATE.SYS_DT is the time since the last scan in seconds. The editor has ready-made snippets for the standard blocks. The same motor, started with a 2.5-second delay:

if !("T1" in STATE) { STATE.T1 = TON(); }
STATE.T1.IN = READ("start_btn") > 0.0;
STATE.T1.PT = 2.5;              // seconds
STATE.T1.CALL(STATE.SYS_DT);
WRITE("motor", STATE.T1.Q);

Testing and commissioning

Alarms and history

Mark a tag as an alarm and give it a text. When its value becomes greater than zero, the alarm appears in the Alarm Log widget with the time, and operators acknowledge it there. Lost device connections and an expired license raise alarms without any setup.

Tag values are written to the SQLite database history.db, at most one point per tag per second, and kept for 30 days unless you change history_retention_days. The History Chart widget shows them.

Backups and moving a project

Export Project (.iui) saves devices, tags, screens, Ladder and ST programs, passwords and MQTT certificates into one file. Import Project (.iui) loads it on this or another server, after copying the current files to project_backups. Keep in mind that the file contains the password hashes, so importing it also brings the editor password of the exported project. license.key is not part of the project: a license belongs to one machine.

config.json

Created in the program folder on the first start. Restart Industrial UI after editing it.

KeyDefaultMeaning
web_host0.0.0.0Network interface to listen on; 127.0.0.1 means this computer only
web_port8080Port of the web interface
history_retention_days30How long history is kept
db_pathhistory.dbHistory database file
data_dir.Folder for project files: tags, screens, devices, passwords
autostart_enabledtrueRun as a Windows service or systemd unit; also switchable in the editor settings

When something does not work

“License.key file not found” or the activation page appears
The license is missing from the program folder. Upload it on the activation page.
“License issued for another server”
The HWID of this machine differs from the one in the license — the mainboard was replaced, the operating system was installed anew, or the machine is a clone of another. A paid license can be re-issued to a new HWID from your account.
A device stays “Disconnected”
Check that the computer can reach the device address and port. A serial device must send a line at least every 5 seconds.
Web API values stopped updating
The source itself is often down or limits requests. Open the URL in a browser: if it does not answer there, Industrial UI cannot read it either.
Windows reports “Access is denied” when autostart is switched
Registering a service needs administrator rights. Close Industrial UI, right-click the program, choose “Run as administrator” once and switch it again.

Still stuck? Use the Contact link at the bottom of the page.