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.
- Open the page and copy the Hardware Code (HWID), 64 characters long.
- 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.
- When the license is signed (you get an email as well), download
license.keyfrom your account. - Upload it on the activation page. Industrial UI checks the file and starts. Copying
license.keyinto the program folder and restarting works too.
A few things worth knowing before you choose the machine:
- The HWID is made of the board of the computer and the installation of its operating system; network adapters, Wi-Fi and VPN do not change it. Read it on the machine you will use in production: a new mainboard or a new installation of the operating system changes it, and a virtual machine cloned by its hypervisor has a HWID of its own. A paid license can be re-issued to a new HWID once, from your account.
- The license limits connections. Every device counts as one: a PLC, an MQTT broker, a serial device, a Web API source or a virtual controller.
- The license is checked on the machine itself, so Industrial UI never needs internet access. When a term license runs out, Industrial UI raises an alarm.
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.
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:
- Viewer password. By default anyone who reaches the server can watch the screens. Set a viewer password in the editor settings, and viewing requires signing in too.
- Screen PIN. Any single screen can be locked with its own PIN — handy for a service page that operators should not open.
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.
| Protocol | Typical use | Tag address |
|---|---|---|
| Modbus TCP | PLCs, energy meters, drives, remote I/O, ESP32 over Wi-Fi | 10, 10.3 |
| Siemens S7 | S7-1200, S7-1500, S7-300 and S7-400 PLCs | DB1.DBW10, M10.2 |
| MQTT, MQTT (TLS/SSL) | IoT sensors, gateways, home automation | The topic |
| Serial Port | ESP32, Arduino and other boards over USB or RS-232 | 0, 0.3 |
| Web API (HTTP/JSON) | Weather, exchange rates, energy prices, other systems | main.temp |
| SoftPLC (Internal) | The virtual controller | Tags 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.
- INT16 — one holding register:
10. - FLOAT32 — two consecutive holding registers starting at the address, high word first.
- BOOL — a coil (
10) or one bit of a holding register (10.3, bits 0–15).
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:
- The device sends lines of comma-separated values ending with a newline:
23.5,1,0,512. A tag address is the position in the line, counting from 0, or one bit of an integer value:3.0. - When an operator changes a value, Industrial UI sends a line
<address>=<value>, for example1=1or0=21.5. For a bit address such as3.2, the device sets that bit in its own value. - Send lines regularly. If the port stays silent for 5 seconds (or for the poll interval, if it is longer), Industrial UI treats the link as lost and reopens the port.
- The poll interval sets how often a line is processed, 50 to 1000 ms; 0 processes every line. The actual line rate appears in the tag
sys_serial_lps_<device>.
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.
| Register | Content | Access |
|---|---|---|
0–3 | Analogue inputs, 0–4095, smoothed | read |
4–5 | Two 16-bit words from an I2C device at address 0x40 | read |
6 | Digital inputs, one bit per input | read |
7–14 | PWM outputs, 0–1023 (the USB sketch uses 7–11) | write |
15 | Digital outputs, one bit per output. Wi-Fi sketch: bit 0 gives a 250 ms pulse, bit 8 drives the on-board LED | write |
Tags for this board in Industrial UI could look like this:
| Tag | Type and access | Address | Widget |
|---|---|---|---|
tank_level | INT16, read-only | 0 | Level Bar, 0–4095 |
door_closed | BOOL, read-only | 6.1 | Lamp |
fan_speed | INT16, read-write | 7 | Slider, 0–1023 |
pump | BOOL, read-write | 15.2 | Toggle Switch |
Over Wi-Fi: Modbus TCP — LOLIN S2 Mini (ESP32-S2)
- 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.
- Upload the sketch. On the first start the board opens the Wi-Fi access point
ESP_Modbus_Setup: connect to it, openhttp://192.168.4.1, choose your network and give the board a static IP address. - 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)
- Choose the board ESP32 Dev Module; no extra libraries are needed. Upload the sketch.
- In Industrial UI add a Serial Port device with the board's port, 115200 baud and a poll interval of 100 ms.
- 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:
SYS_FIRST_SCANis on during the first cycle of the virtual controller after a start — use it to set initial values.SYS_ALWAYS_ONandSYS_ALWAYS_OFFare constants for Ladder programs.SYS_TIME_H,SYS_TIME_M,SYS_TIME_S,SYS_DATE_D,SYS_DATE_DOW(1 = Monday),SYS_DATE_MONandSYS_DATE_Ygive the server clock.
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.
| Group | Widgets |
|---|---|
| Values and input | Display 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 |
| Controls | Switch and Toggle Switch for on/off, Push Button for momentary commands, Hotkey Button for keyboard shortcuts, Slider |
| Indicators | Lamp, LED Indicator, Halo Indicator, Warning Triangle, Level Bar for tanks, Gauge, V-Scale |
| Trends | Live Chart for values as they change, History Chart for the stored history |
| Process graphics | Motor, Valve, Pipe and Joint for mimic diagrams, Image, State Image that changes with a value, IP Camera |
| System | Alarm 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.
- Contacts: normally open
─┤ ├─, normally closed─┤/├─, rising edge─┤P├─and falling edge─┤N├─, which pass power for one scan. - Coils: normal
( ), set(S)and reset(R)for latching, negated(/)and toggle(T), which flips the tag on every rising edge — one button for on and off. - Function blocks: 40 of them — timers, counters, comparisons, math, signal processing, PID with self-tuning, PWM, sequencer, schedules, calendar and astronomical clock, operating hours and totalizers. Each one is described with typical uses in the Ladder block reference.
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
- ⚡ Watch & Force in the toolbox shows live values and lets you force tags to fixed values, so logic can be tested before the real inputs are wired. Unforce All releases everything at once.
- Every deployed version of a Ladder program is archived; ⟲ History in the editor rolls back to an earlier one.
- Retentive values — TONR, STEP, RUNTIME, TOTALIZER, DAYLEN and the learned AUTOPID gains — are kept across restarts.
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.
| Key | Default | Meaning |
|---|---|---|
web_host | 0.0.0.0 | Network interface to listen on; 127.0.0.1 means this computer only |
web_port | 8080 | Port of the web interface |
history_retention_days | 30 | How long history is kept |
db_path | history.db | History database file |
data_dir | . | Folder for project files: tags, screens, devices, passwords |
autostart_enabled | true | Run 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.