Getting Started
ModbusLens is a Modbus TCP and RTU client built for testing, commissioning, and troubleshooting
industrial devices - PLCs, drives, meters, and gateways. It combines the things you'd normally
reach for several separate tools to do: reading and writing coils/registers, monitoring named
tags, graphing values over time, simulating a slave device, running repeatable test scripts, and
scanning a network for Modbus devices.
What you need before you start
- The target device's Modbus TCP address/port, or its Modbus RTU serial settings
(COM port, baud rate, parity) if it's a serial device.
- The device's Unit ID (also called Slave ID or Station Address) - commonly 1, but check
the device's manual or DIP switches/configuration.
- Ideally, the device's register map - which addresses hold which values, and in what format.
If you don't have one, the Raw (Hex) column and the Address Table are good tools for
reverse-engineering it safely (read-only first).
Basic workflow
- Click Settings (top right) and enter the target device's IP address, port (usually 502),
and Unit ID - or switch to Modbus RTU and enter the serial parameters instead.
- Click Connect. The status indicator on the left turns green when connected.
- Use the tabs below to work with the device: Address Table for quick reads/writes,
Tags for named live monitoring, Raw Data for the untouched bytes behind every
transaction, Trend for graphing, Server to act as a slave device yourself, and
Script to automate a test sequence.
Menu bar
See the Menus & Toolbars topic on the left for a full, step-by-step list of every
menu and option and exactly where to find it.
See the topics on the left for details on each part of the app, and check
Troubleshooting if something isn't behaving the way you expect.
Menus & Toolbars
Every menu, every option, and exactly what it does - a full reference for navigating the app.
The menu bar sits at the very top of the window, below the title bar.
File menu
- New Connection Window - opens a second, fully independent ModbusLens window with its
own connection, tabs, and Server tab. See Multiple Windows for details.
- New Session - disconnects if connected, stops monitoring, and clears the current
window's logs and monitoring results. Doesn't close the window or lose your Tags/script - just
resets the live state.
- Save Session / Load Session - saves or loads a single
.mlsession
file bundling connection settings (TCP or serial, Fast LAN Mode, interface binding), the Tags
list (including scaling), the Address Table's current range config, and any write bounds set on
the live connection - everything Export/Import CSV doesn't cover on its own (CSV is Tags
only). Loading a session applies connection settings and the Address Table range immediately, but
deliberately does not connect for you - the same reasoning as picking a Recent Connections entry,
so loading a file can never be the thing that reaches real equipment. Write bounds only exist on
the live connection, so a loaded session's bounds are applied the moment you actually connect
(right away if already connected, or as soon as the next connection succeeds).
- Export Data - not implemented yet; currently shows a placeholder message. Use
Log to CSV on the Tags or Trend tab for live data logging in the meantime.
- Exit - closes this window (saving its settings first).
View menu
- Theme - a submenu with three options: Light, Dark, and
Follow System (matches your OS setting). Only one is active at a time. Picking a different
one asks to confirm, then restarts ModbusLens to apply it - the theme is set once at startup
rather than switched live.
Tools menu
- Connection Settings - opens the same dialog as the Settings button on the
connection bar: choose Modbus TCP or Serial (RTU/ASCII) and enter the target address/port or COM
port parameters. See Connecting to a Device.
- Connection Profiles / Data Templates - not implemented yet; currently show a
placeholder message. Reserved for saving/reusing connection setups and register layouts in a
future release.
- IP Configuration - a quick, read-only ipconfig-style view of this machine's own network
adapters (name, IP, subnet). Useful for figuring out which subnet to scan or connect on before
you know a device's address.
Diagnostics menu
- Network Discovery & Diagnostics - opens the network scanning dialog (ARP-based
device discovery plus Modbus detection). See Troubleshooting > Network Discovery.
- Serial Discovery - opens a dialog that sweeps common baud rate/parity/stop-bit/Unit ID
combinations against a COM port to find which one a serial device actually speaks. See the
Scanner topic.
- Modbus Diagnostic Functions - opens a dialog covering the function codes beyond basic
read/write: Read Exception Status, Diagnostics (Loopback, Restart Communications, Read Diagnostic
Register, Clear Counters), Get Comm Event Counter/Log, Report Server ID, Read/Write File Record,
Mask Write Register, Read FIFO Queue, and Read Device Information. See the
Diagnostic Functions topic.
- Decode Registers - opens a standalone hex decoder, no connection required. See the
Data Decoder topic.
- System Logs - opens a dialog with the full, scrollable System Logs history (the same
color-coded write/connect/error log shown live in the app). Handy when you need to scroll back
further than fits on screen. Ctrl+scroll wheel zooms its text size in and out.
- Clear All Logs - clears the System Logs and the Raw Data tab's transaction history.
This can't be undone.
Help menu
- Documentation - this dialog.
- About - version number, a feature list, the Support link, and an Updates tab
that checks GitHub Releases for a newer version.
Connection bar (below the menu bar)
Not a menu, but always visible and worth listing here: the status indicator (left), current
target address, and on the right, Settings (same as Tools > Connection Settings),
Connect, and Disconnect.
Connecting to a Device
Open Settings in the top-right of the connection bar. Choose Modbus TCP or
Modbus Serial (RTU/ASCII) at the top of the dialog, then fill in the fields for that mode.
Modbus TCP
- IP Address - the target device, PLC, or gateway's Modbus TCP address.
- Port - usually 502 for standard Modbus TCP.
- Network Interface - Auto (let OS choose route) is the default: it fills in a
local interface's IP as a convenience when Target IP is blank (e.g. testing against ModbusLens's
own Server tab), but otherwise leaves routing entirely to the OS, same as older versions. Picking
a specific interface instead actually binds the outgoing TCP socket to it - useful on a
multi-homed machine (VPN + Ethernet + Wi-Fi all up at once) where the OS's default route isn't the
NIC you actually want the connection to go out of. If you need the full picture - every adapter's
IP and subnet mask - Tools > IP Configuration shows all of them at once, like running
ipconfig.
- Fast LAN Mode - a short (200ms) timeout and no retries, for a local network where a
timeout means the device is actually gone rather than just momentarily slow. It also changes how
Tags monitoring reacts to a failed poll - see Tags Monitoring.
Modbus Serial (RTU/ASCII)
- Serial Port - the COM port the device is connected to (via USB-RS485/RS232 adapter or
a native serial port).
- Baud Rate, Parity, Stop Bits, Byte Size - must match the device's configuration exactly,
or communication will fail or return garbage.
- Framing - RTU (binary, the default and far more common) or ASCII
(hex-encoded, human-readable on the wire, framed with a leading
: and trailing
CR/LF). The two are incompatible - a device speaking one will not respond correctly to the other,
so this has to match the device exactly, the same as baud rate and parity.
Both modes
- Unit ID - the Modbus slave/unit identifier (1 is common; some TCP gateways ignore it,
but RTU devices almost always require the correct one).
- Recent Connections - quickly reconnect to somewhere you've connected before.
Click Connect to open the connection, Disconnect to close it. The status
indicator on the left shows Connected (green), Connecting (orange), or Disconnected/Error. If the
connection fails, ModbusLens shows a dialog with the specific error and a checklist of likely
causes - see Troubleshooting for the details behind that checklist.
Finding your own IP (IP Configuration)
Tools > IP Configuration opens a small window listing every network adapter on this
machine - the same information ipconfig gives you at a command prompt, without
leaving the app. Each row shows the adapter name, its IPv4 address, subnet mask, and whether the
adapter is Up or Down.
This is mainly useful for two things: figuring out which of your adapters is on the same
network/subnet as the target device before you connect, and getting the right address to hand out
when you're the target - e.g. telling a colleague's PLC or SCADA system which of your IPs to
connect to when testing against ModbusLens's own Server tab. The Network Interface dropdown in
Settings (above) covers the common case of "fill in my own IP"; IP Configuration is for when you
need to see every adapter and its subnet mask at once, such as confirming two adapters aren't
sharing a conflicting subnet.
Auto-reconnect
Once connected, ModbusLens watches the connection in the background. If it drops - a cable
pulled, a device rebooting, a network blip - the status indicator switches to
Reconnecting... (attempt N) and it keeps retrying automatically, waiting a little longer
between each attempt (2s, 4s, 8s... capped at 30s) so it doesn't hammer a device that's still
coming back up. If Tags monitoring was running and got stopped because every tag failed at once
(read as: the connection itself was the problem, not one bad tag), it resumes automatically the
moment the connection recovers - you don't have to click Start Monitoring again. This only applies
to an unexpected drop; clicking Disconnect yourself never triggers a reconnect attempt.
0-based vs 1-based addressing
Modbus devices are documented two different ways: some vendors say "register 40001" meaning
protocol offset 0 (1-based/traditional), others say "register 0" meaning the same offset
(0-based/raw). The 0-Based Addressing checkbox on the Address Table and Tags tabs controls
which convention the address field uses:
- Unchecked (default): 1-based. Entering address 1 reads protocol offset 0, matching the
classic 40001-style convention.
- Checked: 0-based. Entering address 0 reads protocol offset 0 directly.
If a value looks off by one compared to what you expect, this is almost always the cause -
toggle the checkbox and compare. See Troubleshooting for more on diagnosing this.
Address Table
A quick read/write grid for a contiguous range of one Modbus data type, similar to classic
tools like ModScan. This is usually the fastest way to sanity-check a connection or probe an
unfamiliar register map before setting up named Tags.
Creating a table
- Pick a Function (Read Coils, Read Holding Registers, Write Single Register, etc.).
- Set Start Address and Count.
- Click Create Table.
Each row shows the Modbus reference address, the current value, and the same value in hex.
For a Write function, double-click the Value cell to edit and send it immediately (coil rows
show a checkbox instead).
Write bounds
For Write Single/Multiple Register functions, two extra columns appear: Min and
Max. Setting both on a row rejects any write to that register outside the range - a typo
like an extra zero gets refused instead of sent to the field device. This is enforced on the
connection itself, so it also protects writes to that same address from the Tags tab or a
Script, not just from this table. Leave both blank for no limit. A rejected write shows up as a
failed write with a message like "Write rejected: value 10000 at address 99 is outside the
configured write bound [0, 100]".
Live Monitoring
For Read functions, check Enable Live Monitoring and set an interval to keep polling
the whole range automatically. Starting this auto-stops Tags monitoring, and vice versa, so the
two don't compete for the connection.
Status Log
The panel on the right shows what the table is doing - reads, writes, and any errors,
each with a timestamp and color-coded so the right lines stand out: writes in blue, connection
events in green, errors in red, everything else in the default color. This is the first place
to look when a write doesn't seem to take effect. The same coloring applies to System Logs and
the Script console. Ctrl+scroll wheel over the log zooms its text size in and out.
Tags Monitoring
The Tags tab lets you name individual points scattered across different addresses and types,
and watch or write them all at once - unlike the Address Table, which is one contiguous range.
This is the tab you'll spend the most time in once you've mapped out a device: build the list
once, then monitor it continuously.
Adding tags
Add Tag appends a new row (or inserts just below the selected row, so you're never stuck
adding things only at the end). Once a list is built, drag a row's number in the left-hand gutter
up or down to reorder it - a blue line shows exactly where it will land as you drag, and the row's
live values, comment, and alarm configuration (if any) all move with it. Reordering is only
available while monitoring is stopped, the same as adding or removing a tag. Each row has:
- Tag Name, Mode (Read or Write), Type (Coil/Discrete Input/Holding
Register/Input Register), Address, Count, Format.
- Read Value - the decoded live value.
- Raw (Hex) - the same value in hex, straight from the register(s), independent of format.
- Write Value - type a value here and click Write Selected to send it (Write-mode
tags only), or just press Enter in the cell to write that one row immediately without
selecting it or clicking anything - mirrors the type-and-Enter workflow classic tools like Modbus
Poll use. Goes through the exact same confirmation and safety interlock as Write Selected.
- Comment and Timestamp.
Naming a tag
A tag name can only use letters, numbers, and underscores - no spaces or other symbols, and it
can't start with a digit. Script language keywords (WRITE, READ, HR, COIL, and so on) and common
reserved words are also blocked. Entering an invalid name shows a warning and clears the field
rather than letting it stick, since a tag's name is also how it's referenced by name in a Script
(see the Scripting topic) and in Trend's Add Pen picker.
Data formats
Bool, U16/S16, U32/S32/F32, U64/S64/F64 (plus _SWAP
variants of the 32/64-bit formats for the opposite word order), and Hex. BOOL on a
Coil/Discrete Input is a simple flag; BOOL on a Holding/Input Register instead shows the full
16-bit pattern (e.g. 0000000000000101) so you can read individual status/alarm bits
out of a status word. 32-bit formats (U32/S32/F32) need a Count that's a multiple of 2;
64-bit formats (U64/S64/F64) need a multiple of 4 -- since they span 2 or 4 registers per value,
respectively.
Engineering-unit scaling
Check the Scale box on a row to open a small popup with two modes:
- Linear (Min/Max) - Raw Min/Max and Scaled Min/Max define a linear
transform from whatever the device actually sends to a meaningful engineering unit (e.g. raw ADC
counts 0-4095 mapped to 0-100 PSI).
- Multiply by Constant - a single factor (e.g. raw 151 x 0.1 = 15.1), for the common case
of a device sending a value scaled by a fixed power of ten.
Either way, the result appears in the Engineering Value column alongside the normal Read
Value, live as the tag is polled - and in Trend, if a pen's tag has scaling enabled. Choose
Real or Integer for how the scaled result is displayed. Unchecking Scale (or
Cancelling the popup) turns scaling back off for that row.
Alarms
Right-click a tag row and choose Configure Alarm.... Numeric tags get a High and/or
Low limit; coils, discrete inputs, and BOOL-format registers get an ON/OFF trigger instead. The
Read Value cell turns red while the tag is in alarm.
Logging
Log to CSV appends a timestamped row for every monitored tag on every poll tick to a
file you choose. Export CSV/Import CSV save or load the tag list itself (not the
live data) - handy for keeping a reusable tag set per device model.
Resilience while monitoring
A single tag that fails to read (bad address, wrong count for its format, device briefly
unresponsive) shows ERROR in its own Read Value cell but doesn't stop the rest of
the list from updating. Monitoring only auto-stops if every tag fails on the same poll,
which is treated as a lost connection rather than a configuration mistake on one row.
Fast LAN Mode (Connection Settings, TCP only - see Connecting to a Device) changes this
slightly: after the first failed read in a poll cycle, it does one quick reachability check
against the device instead of moving straight to the next tag. If the device is actually gone,
every remaining tag in that cycle is marked ERROR immediately rather than each one
individually timing out. If the check finds the device still reachable, polling continues
normally - that one failure was specific to a single tag, not the connection.
Safety interlock
Writing is paused for the moment a value is being sent, then Read polling resumes automatically -
this stops a write and a read from overlapping on the same connection.
Raw Data
One row per Modbus transaction - the untouched data behind every read and write, independent
of how a Tag or Address Table row happens to decode it. Useful when a decoded value looks wrong
and you want to see exactly what came back before ModbusLens interpreted it as U16, F32, or
anything else, and for spotting a device that's responding but slow.
Ctrl+scroll wheel over the table zooms its text size in and out.
Columns
- Time and Operation - when it happened and what it was (which Tag, Address
Table function, or Script command triggered it).
- Value - the raw register/coil result in decimal, or the error message if it failed.
- Raw (Hex) - the same result in hex (registers as
0xNNNN, coils as
1/0) - blank for a failed transaction, since there's nothing to show.
- TX Bytes / RX Bytes - the literal bytes ModbusLens sent and received on the
wire for that exact transaction, captured straight from the connection itself. This is one level
more raw than the Value/Raw (Hex) columns - those show the register values after pymodbus has
already parsed the response; TX/RX Bytes show the full frame as bytes, including the function
code, address, byte count, and (for serial) the CRC/LRC. RX is blank if the request timed out
with no response at all.
- Status - Success or
Failed, color-coded the same way as the other logs.
- Exception - the decoded Modbus exception description (e.g. "Illegal Data Address")
when the device itself replied but refused the request. Left blank for a plain communications
failure (timeout, no response at all), so you can tell "the device rejected this" apart from
"nothing answered" at a glance instead of both just showing Failed. Hover over an Exception
cell for a tooltip with the exception's plain-English meaning and a short list of likely
causes (e.g. for Illegal Data Address: wrong start address, a 0/1-based addressing mismatch,
the wrong register space) - a starting point for what to check next, not just a name to go
look up.
- Latency (ms) - how long that specific request took round-trip. A device that's
technically working but degrading usually shows up here first, before it starts failing outright.
Filter
The text box filters by whatever's in the Operation, Value, or Exception columns - a tag name
(e.g. pump), an address, a specific value, or part of an exception message. The
dropdown next to it narrows to just Success or Failed rows. Both apply live as you
type/select, and to new rows as they arrive - useful for watching one specific tag during a busy
poll, or isolating every failure to see if they cluster around one address.
Show Statistics
Opens a summary of the current connection's traffic: total requests, successful vs. failed
counts, exception responses, and average/min/max response times across everything logged so far
(not just what's currently visible in the table, since old rows fall off after 1000). Useful for
confirming a "slow" feeling is real and quantifying it.
Clear Data
Empties the table (and the Frame Viewer panel below it) without affecting the connection or any
other tab. Do this before reproducing an intermittent issue so the table only contains the run you
care about.
Export CSV
Saves whatever rows are currently visible - i.e. respecting the text/status filter - to a CSV
file, in the same column order as the table.
Right-click / Ctrl+C
Right-click a row (or a multi-selection of rows) for Copy Row(s) as Text - every column,
tab-separated, ready to paste into a spreadsheet - or Copy Row(s) as Hex Bytes (TX/RX),
which copies just the literal wire bytes without the decoded columns in the way. Ctrl+C does
the same as "Copy Row(s) as Text" on whatever's currently selected.
Frame Viewer
Below the table, decodes whichever transaction row is currently selected into its TX and RX
Modbus frames side by side - not just the register values, but the actual frame structure: the
MBAP header (Transaction ID, Protocol ID, Length, Unit ID) for TCP, or Unit ID plus CRC/LRC for
RTU/ASCII, then the function code, data bytes, and any exception code, plus the raw hex for both
directions along the bottom.
Select a row by clicking it or by navigating with the arrow keys, Home/End, or Page Up/Page
Down - the Frame Viewer updates immediately either way, and the table scrolls to keep the selected
row visible even while new transactions keep arriving underneath you.
The Hide Frame Viewer button (next to Export CSV) collapses the panel so the table gets
the tab's full height when you just want to scan through transactions - click it again
(Show Frame Viewer) to bring it back; the last decoded frame is still there, not cleared.
Every part of the decoded frame can be selected and copied: right-click (or Ctrl+C on a
selection) either the TX or RX field table for Copy Row(s) - tab-separated, ready to paste
into a spreadsheet. The raw hex line along the bottom can be click-dragged to select and copied
directly (Ctrl+C or right-click) like any selectable text, for pasting straight into a hex viewer
or a bug report.
Trend
Graphs up to 20 pens over time, either following the live clock or reviewing history. Useful
for spotting slow drift, verifying a control loop is actually responding, or capturing a transient
you can't watch a numeric table fast enough to catch.
Pens
Add Pen opens a grid of 20 slots (SCADA-style) - enable the ones you want, click the
⋮ button in the Name column to pick a tag from a popup list, and set a color. A pen's type,
address, count, and format all come from whichever tag you pick, not from separate fields here.
Only tags on Holding or Input Registers with a numeric format show up in that list - Coils,
Discrete Inputs, and the Bool format are left out, since a trend line is meant for continuously
varying values rather than on/off state. If you need to watch a digital point over time, add it
as a Tag and check its Read Value column instead. If none exist yet, the popup's tag list is just
empty - click its Add Tag... button to jump straight to the Tags tab and create one (this
closes the Trend Pens grid, since adding a tag needs the Tags tab visible). If the tag has
engineering-unit scaling enabled (see Tags Monitoring), the pen plots that scaled value instead
of the raw one - turning scaling on or off for the tag changes what the pen shows immediately,
no need to re-pick it.
Navigating
If the view is sitting at the live edge (showing right up to "now"), it keeps following as new
data arrives, the same as before. As soon as you scroll or zoom away to look at something earlier,
it stops following and stays exactly where you left it, however long the trend keeps running -
new data doesn't interrupt you. Scroll back to the live edge and it picks up following again on
its own; there's no separate mode to switch. Everything plotted is just what's been collected in
the current session - there's no separate historical database to switch into.
The scrollbar just below the graph pans through everything collected so far, including while
the trend is actively running - drag it right to catch up to the newest data, or left to look back.
Time Window picks how much time is visible at once. Zoom In/Out halves or doubles
that span around wherever you're currently looking. From/To plus Go jumps
straight to a specific range.
Hovering the mouse over the graph drops a crosshair line and updates the legend below each
pen's name with its value at that point in time, so you can read an exact number off the trace
without switching to CSV logging. The Min/Max/Average table below the graph updates to match the
hovered point too; move the mouse away and everything goes back to showing the current live
values.
Graph Properties
Set the X and Y axis titles, background/axis/grid colors, whether gridlines are shown, and
whether the Y axis auto-ranges to the data or uses a fixed Min/Max. Gridlines default to black;
change them here if you'd rather have something more subtle for a printed report.
Logging and printing
Log to CSV appends a timestamped row per pen on every poll tick. Print saves the
current graph view as a PNG image or a PDF document - useful for attaching evidence of a fault
condition to a service report.
Detach
Detach pops the whole Trend view out into its own resizable, maximizable window that
stays on top of the main window, so you can watch it while working in another tab (Tags,
Script, ...) instead of switching back and forth. The Trend tab itself shows a red X while
detached, as a reminder that the real view has moved. The floating window's Hide Stats
button collapses the Min/Max/Average table to give the graph more room - this button only
appears while detached, since the docked tab isn't usually short on space. Click Fixed at
the bottom of the floating window (or just close it) to dock the view back into its tab.
Server Mode
The Server tab makes ModbusLens act as a Modbus TCP slave device instead of a client -
useful for testing your own SCADA/PLC program against a fake device, without needing real
hardware on hand, or for validating a Script or Tag configuration before pointing it at
production equipment.
Starting the server
- Set Server Address (usually
0.0.0.0 to accept connections on any
network interface), Port, and Unit ID.
- Click Start Server.
Once running, pick a Data Space (Coils, Discrete Inputs, Holding Registers, or Input
Registers), set a Start Address/Count, and click Load to view that range.
Editing values
Double-click a Value cell to set it directly, as if you were the field device generating
that reading. Any Modbus master that connects to this server sees the same value. Coils and
Holding Registers are also writable by a remote master; Discrete Inputs and Input Registers are
read-only from the network side (as in real Modbus), but you can still set them yourself from
the GUI to simulate a live sensor.
One server at a time
Only one Server tab can be running at once, across every open window. ModbusLens builds the
simulator on pymodbus's
ModbusSimulatorContext/ModbusServerContext for the datastore and its
StartTcpServer helper to run the listener. StartTcpServer spins up its
own asyncio event loop in the thread that calls it and is meant to run one instance per process -
it isn't designed to have two independent listeners active at the same time. ModbusLens runs it in
a background thread and tracks the single active instance itself; starting a second one while
another is running shows a Server Already Running message. Stop the first one to free it up.
Connecting to your own server
Open a second ModbusLens window (File > New Connection Window), connect it to
127.0.0.1 (or your machine's LAN IP) on the port the server is listening on, and you
have a complete self-contained loop for testing Tags, Trend, or a Script with zero risk to real
equipment.
Scripting
The Script tab runs small test sequences against the connected device using a purpose-built
command language - not a general-purpose one, just enough to write values, wait, read them back,
and repeat. It's meant for repeatable acceptance tests, burn-in sequences, and quick automated
checks you'd otherwise click through by hand every time.
Commands
WRITE COIL <addr> = ON|OFF | Write a coil. |
WRITE HR <addr> = <expr> | Write a holding register. |
WRITE <tag name> = <expr> | Write to whatever type/address that tag is configured for. |
READ COIL|DI|HR|IR <addr> | Read a value and log it. |
READ <tag name> | Same, by tag name instead of type/address. |
LET <name> = <expr> | Assign a variable. |
LOG <expr> | Print text/numbers to the console. |
WAIT <expr, ms> | Pause without freezing the UI. |
REPEAT <expr> ... END | Loop a block of commands a fixed number of times. |
REPEAT UNTIL <expr> <op> <expr> ... END |
Loop until a condition becomes true, checked before each pass. |
IF <expr> <op> <expr> THEN <command> |
Run one command conditionally. op is == != > < >= <=. |
Expressions
An expression can mix numbers, "strings", variables, parentheses, and
+ - * /. Writing a bare HR 0 inside an expression reads that register
inline (shorthand for READ HR 0); a bare tag name works the same way (e.g.
LET x = Boiler_Temp + 1 reads the Boiler_Temp tag's current value). A tag name is
only tried if the name isn't already a variable you've assigned with LET - a LET
variable always takes priority over a tag of the same name. + also concatenates text
with numbers, so LOG "value is " + x works as expected. Types: COIL, DI (Discrete
Input), HR (Holding Register), IR (Input Register).
Compile and Run
Compile checks the script's syntax without touching the device - use it to catch typos
before running anything. Run executes the script step by step; because it can write to a
live device, Run shows a one-time-per-preference warning first (with a "don't remind me again"
option) reminding you to be careful on in-service equipment. Stop halts a running script
at any point.
Target: Client vs Server
The Target dropdown picks whether the script talks to your live connection
(Client-target) or to ModbusLens's own Server tab (Server-target), so you can dry-run
a sequence safely with no real device attached - start a server, switch the script to
Server-target, and run it exactly as it would run against the real thing.
Variables panel
The panel on the right lists every variable your script assigns with LET, updating
live as the script runs - no need to sprinkle LOG lines everywhere just to see what a
variable currently holds. It populates as soon as you Compile (values blank until the script
actually runs), keeps updating on every step while running, and holds the last values after the
script finishes or is stopped, so you can still read them afterward.
Other tools in the editor
- Add Tag - opens a popup listing every tag on the Tags tab (any type, not just analog),
and picking one drops its name straight into the script at the cursor. If the tag you need doesn't
exist yet, the popup's own Add Tag... button jumps to the Tags tab with a new, blank row
ready to name and configure.
- Insert Tag - the right-click menu shortcut for the same thing: drops a tag's name
straight into the script at the cursor, so you don't have to remember or retype it - the script
then resolves it against whatever that tag is currently configured as (see WRITE/READ above), so
editing the tag later doesn't require touching the script.
- CPU usage indicator - shows live system CPU load, useful for spotting a runaway loop
that's spinning the interpreter faster than intended.
Sample Scripts
A few complete, working examples to adapt - copy one into the editor, adjust the addresses for
your device, and Compile before Run.
1. Basic write/read/log sequence
LET x = HR 0 + 10
WRITE HR 1 = x
WAIT 500
LOG "HR1 is now " + x
IF HR 1 >= 100 THEN LOG "over threshold"
Reads holding register 0, adds 10, writes the result to register 1, waits half a second for
the device to settle, then logs and checks it against a threshold.
2. Toggle a coil N times (blink test)
REPEAT 5
WRITE COIL 0 = ON
WAIT 250
WRITE COIL 0 = OFF
WAIT 250
END
LOG "Blink test complete"
Good for a quick relay/output wiring check - watch the physical output or an LED toggle five
times, half a second per cycle.
3. Poll a register until it reaches a target value
LET attempts = 0
REPEAT 60
LET attempts = attempts + 1
IF HR 2 >= 500 THEN LOG "Target reached after " + attempts + " checks"
WAIT 1000
END
LOG "Done polling"
Checks holding register 2 once a second for up to a minute - useful for waiting on a startup
sequence, a warm-up temperature, or any value that changes slowly on its own. Plain
REPEAT doesn't have a break/exit, so this always runs the full 60 checks even after
the target is reached; it's a bounded polling window with a built-in timeout. See the next example
for a version that stops the instant the condition is met.
4. Wait until a value is reached, no fixed check count
REPEAT UNTIL HR 2 >= 500
WAIT 1000
END
LOG "Target reached"
Same idea as the previous example, but stops the moment holding register 2 hits 500 instead of
always running a fixed number of checks - and if it's already >= 500 before the loop starts, the
body never runs at all (the condition is checked before each pass). If the condition never becomes
true, this stops on its own with a clear error after a very large number of iterations rather than
hanging forever - see Limits below.
5. Ramp a setpoint up gradually
LET setpoint = HR 10
REPEAT 10
LET setpoint = setpoint + 5
WRITE HR 10 = setpoint
LOG "Setpoint now " + setpoint
WAIT 2000
END
Steps a holding register up by 5 every 2 seconds instead of jumping straight to a final value -
useful for equipment that shouldn't see a large setpoint change all at once.
6. Read several points and log them together
LET temp = HR 0
LET pressure = HR 1
LET running = COIL 0
LOG "Temp=" + temp + " Pressure=" + pressure + " Running=" + running
A one-shot snapshot across mixed types (registers and a coil) in a single readable log line -
handy at the start or end of a longer script to record a baseline.
7. Conditional checks with IF
LET temp = HR 0
IF temp > 90 THEN LOG "WARNING: temperature high (" + temp + ")"
IF temp < 10 THEN LOG "WARNING: temperature low (" + temp + ")"
IF temp == 0 THEN LOG "Sensor may be disconnected"
IF COIL 0 != 1 THEN WRITE COIL 1 = ON
Each IF only runs one command when its condition is true, and there's no
ELSE - that's why this reads as a sequence of independent checks rather than a single branching
block. The last line shows an IF driving a WRITE instead of a LOG: turn on coil 1 (e.g. an alarm
lamp) whenever coil 0 (e.g. "running") isn't set. Valid comparisons are
== != > < >= <=, and either side can be a register/coil read, a
variable, or a literal number.
8. The same thing, by tag name instead of type/address
IF Boiler_Temp > 90 THEN LOG "WARNING: temperature high (" + Boiler_Temp + ")"
WRITE Pump_Enable = ON
Assumes a Boiler_Temp (Holding/Input Register) and Pump_Enable
(Coil) tag already exist on the Tags tab - use Insert Tag to drop the name in without
retyping it. Reads a tag name exactly like HR 0/COIL 0 would, but stays
correct if that tag's address ever changes, since the script only cares about the name.
Limits
To keep a typo from hanging the app or running forever: a loop with no WAIT still hands
control back to the interface regularly instead of freezing it, and REPEAT counts, WAIT
durations, expression nesting, and total script length are all capped with a clear error if
exceeded. REPEAT UNTIL shares that same iteration cap - if the condition never
becomes true, it stops with an error instead of looping forever. Separately, consecutive steps are
never scheduled less than 20ms apart even if a script asks for WAIT 0 or omits WAIT
entirely, so a typo can't flood the device or network. See Troubleshooting for what the
common error messages mean.
Scanner
Auto-discovers which addresses respond on the connected device - useful when you don't have
a register map yet. Works the same way regardless of whether the current connection is TCP or
serial, since it just reuses whatever's already connected.
Pick a Function (Coils/Discrete Inputs/Holding/Input Registers) and a Start/
End address, then Start Scan. Rather than checking one address at a time, it
probes the largest block the function allows first - a clean read means every address in that
block responds. If a block doesn't fully respond, it's split in half and each half is probed
again, narrowing down until it knows exactly which individual addresses do and don't respond.
This is far fewer requests than a naive one-by-one sweep whenever most of a range is
contiguous, which is the common case for a real device's register map.
The Summary line lists the responding addresses as merged ranges (e.g.
0-15, 20, 45-99). A device that returns Illegal Function for the whole
range stops the scan immediately with a clear message, since every address would fail the same
way - try a different Function instead. A genuine timeout or dropped connection also stops the
scan, since that means the device itself stopped responding, not that a particular address is
invalid.
Reuses the same connection as every other tab, rather than opening a second one. Starting a
scan automatically pauses Tags monitoring and Address Table Live Monitoring if either is
running, the same way those two already pause each other, so nothing else polls the connection
while a scan is in progress.
A shorter Probe timeout makes a scan faster but can misreport a slow-but-valid address
as not-responding, especially over a serial connection where every probe is one real bus
round-trip. If a scan seems to be missing an address you know exists, try a longer timeout.
Don't know the serial connection parameters (baud rate, parity, stop bits) for a device in
the first place? See Serial Discovery.
Serial Discovery
Diagnostics > Serial Discovery sweeps common baud rate/parity/stop-bit/Unit ID
combinations against a COM port to find which one a serial device actually speaks, for when its
settings aren't documented. There's also a Scan for Connection Parameters... button in
Tools > Connection Settings's Serial section that closes that dialog and opens this
one directly, with the COM port already filled in.
Pick the COM Port and Framing (RTU/ASCII), a Start/End Unit ID
range to also try, then Start Scan. It tries every combination of 8 common baud rates,
3 parity settings, 2 stop-bit settings, and each Unit ID in the range - byte size is fixed at
8, the near-universal default - opening a short-lived connection for each combination and
sending one Holding Register read. Any reply, including a Modbus exception response, counts as
a match, since that still proves the framing decoded correctly; a garbled response from a
mismatched baud rate won't parse as a valid Modbus frame at all.
A match isn't always unique. Stop bits (and occasionally parity) are framing bits, not
data - many UART/USB-serial adapters only check for at least one high bit-time before the
next start bit, so a receiver set for 1 stop bit is easily satisfied by a sender actually using 2,
and vice versa. It's normal to see the same baud/parity/Unit ID reported as a match at both stop-bit
settings. When that happens, prefer whatever the device's own documentation or configuration
screen actually says over guessing from the scan alone - the scan proves "a read got a response,"
not "these are the device's exact settings."
This doesn't reuse the app's shared connection - it opens its own for each combination,
since testing a physical serial setting means actually reopening the port with it. That also
means the port needs to be free: disconnect ModbusLens first if it's the one connected to this
port, and close any other program (Modbus Poll, a terminal, another ModbusLens window) that
might have it open. If the port can't be opened at all, the scan stops immediately with that
message rather than repeating the same failure for every remaining combination.
Keep the Unit ID range narrow (it defaults to just 1) unless you actually need it wider -
each additional Unit ID multiplies the total combination count by 48.
Diagnostic Functions
Diagnostics > Modbus Diagnostic Functions reaches the Modbus function codes beyond
basic reads/writes - niche next to everyday polling, but a real gap for compliance and interop
testing. Pick a function from the dropdown, fill in the couple of parameters it needs (most need
none at all), and Run. The result - or the device's error - shows in the box below.
- Read Exception Status (FC07) - an 8-bit vendor-specific status byte, a lightweight
"is anything wrong" poll some devices support without a full register read.
- Diagnostics: Loopback / Query Data (FC08) - sends bytes you type (as hex, e.g.
12 34) and expects the device to echo them back unchanged - a pure comms sanity
check that never touches a real register.
- Diagnostics: Restart Communications (FC08) - asks the device to reinitialize its comm
port; optionally also clears its event log/counters.
- Diagnostics: Read Diagnostic Register (FC08) - device-specific status bits (e.g.
listen-only mode); the meaning beyond the raw bits is vendor-defined.
- Diagnostics: Clear Counters (FC08) - clears the device's own diagnostic counters and
register.
- Get Comm Event Counter (FC11) / Get Comm Event Log (FC12) - a free-running
counter the device bumps on every completed transaction, plus (for the Log) a short history of
recent bus events and a ready/busy status flag.
- Report Server ID (FC17) - a vendor-defined identifier string plus a run/stop
indicator, historically called "Report Slave ID."
- Read/Write File Record (FC20/21) - reads or writes records in the device's file
storage, a second address space separate from registers/coils, mostly seen on energy meters and
similar data loggers. Specify the File Number, Record Number, and (for a read) how many registers
to read, or (for a write) the raw data as hex bytes.
- Mask Write Register (FC22) - sets a register to
(current_value AND and_mask) OR (or_mask AND NOT and_mask) atomically on the device,
so changing a few bits doesn't race against another master's write to the same register between a
plain read and write.
- Read FIFO Queue (FC24) - reads a FIFO queue's current contents (without removing them)
from a pointer register, for devices that buffer captured values faster than a master polls
them.
- Read Device Information (FC43) - vendor name/product code/version and similar text
objects, a standardized alternative to a vendor-specific register for "what device am I talking
to." Read Code selects Basic, Regular, Extended, or a single specific object via
Object Id.
Every function here goes through the same connection as everything else in ModbusLens (Address
Table, Tags, Scanner) - you need to already be connected, and a run briefly uses the connection
like any other read/write.
Data Decoder
Diagnostics > Decode Registers opens a standalone "paste hex, see every
interpretation" tool - no connection required, and no live Tag involved. Useful when you have a
raw value from somewhere (the Raw Data tab, a device's datasheet, a captured frame) and don't
know how to interpret it, without creating a Tags-tab row and guessing a Format against a live
device.
Type or paste the raw hex bytes - spaces, commas, and 0x prefixes are all fine
(41 48 00 00, 0x41,0x48,0x00,0x00, and 41480000 all work
the same). The results table updates live as you type: U16, S16, HEX, and
Binary per register, plus U32/S32/F32 once there are at least 2
registers and U64/S64/F64 once there are at least 4, ASCII (only
shown when every byte is printable text) and BCD (only shown when every nibble is a valid
0-9 decimal digit), and each register's individual bits as a 16-character binary string.
The Byte/word order dropdown covers all four standard orderings - ABCD (plain
big-endian Modbus, the default), BADC (the two bytes within each register swapped),
CDAB (register order reversed - the same idea as the Tags table's _SWAP
formats), and DCBA (both). Switching it re-decodes the same bytes immediately, no
re-typing needed - useful for eyeballing which ordering actually makes a device's value make
sense (e.g. a plausible-looking float vs. an implausible one). ASCII and BCD are always read in
the order you typed them, independent of this dropdown, since reordering bytes doesn't make
sense for text or packed-decimal data the way it does for a number.
Unlike every other dialog in ModbusLens, this one stays open when you click elsewhere - it's
meant to sit alongside the Raw Data tab or an external datasheet while you work, not block the
main window.
Multiple Windows
File > New Connection Window opens a second, fully independent ModbusLens window -
its own connection, Address Table, Tags, Raw Data, Trend, Server, Script, and Scanner tab. Use
this to talk to several devices at the same time side by side, or to run a Client against your
own Server tab from a second window (see Server Mode).
The one exception is Server mode: only one Server tab can be actively running at a time,
across all open windows (see the Server Mode topic for why). Everything else is fully
independent per window.
Coming in the next update: true multi-target/multi-connection monitoring - several
devices managed from a single window (named targets, shared Tags/Trend view), instead of today's
one-window-per-connection model. These separate windows already let you work with several devices
at once, but they don't share anything with each other; that's the gap the next update closes.
Troubleshooting
Symptom-first reference for the problems you're most likely to run into. If something here
doesn't cover your case, the Status Log (Address Table) or the console (Script tab) usually has a
more specific message worth reading closely.
Can't connect over TCP
The connection dialog itself lists a checklist when a TCP connection fails; the reasoning
behind each item:
- Is the Modbus server actually running? A gateway or PLC that's powered on but hasn't
started its Modbus service will refuse the connection outright.
- IP address and port correct? Double check for typos, and that you're not pointing at a
different device's management IP instead of its Modbus interface.
- Network connectivity? Try pinging the target first - if that fails, the problem is
routing/cabling, not Modbus.
- Unit ID matches? Some gateways route by Unit ID to different downstream serial
devices; a wrong ID can connect fine but every read/write then fails or returns the wrong
device's data.
- Firewall? Windows Firewall or a network firewall blocking outbound port 502 (or
whatever port you configured) will look identical to the device being offline.
Can't connect over RTU (Serial)
- Does the COM port exist and is it free? Only one application can hold a serial port
open at a time - close Modbus Poll, a terminal program, or another ModbusLens window that might
already have it open.
- Baud rate, parity, stop bits match the device? A mismatch here doesn't always fail
cleanly - it can connect and then return garbage or timeouts instead of an obvious error.
- Cable and power? Check the USB-to-RS485/RS232 adapter is recognized by Windows (Device
Manager) and the device itself is powered.
- Unit ID matches the device's configuration? Same reasoning as TCP above.
Values look exactly one address off
This is almost always the 0-based vs 1-based addressing setting. Toggle the 0-Based
Addressing checkbox on the Address Table or Tags tab and compare - see the Connecting topic
for the full explanation.
A 32-bit or 64-bit value (U32/S32/F32, U64/S64/F64) looks like nonsense
Try the _SWAP variant of the same format. Different vendors order the registers of
a multi-register value differently, and there's no reliable way to detect which one a device
uses - it's trial and error. The Tags table's Raw (Hex) column shows the untouched register
bits regardless of format, which is the fastest way to confirm your mapping once you find the
right combination.
A write silently didn't happen
- Check the Status Log (Address Table), the tag's row, or the Raw Data tab - a rejected write
shows a specific reason rather than just failing quietly, and Raw Data will show it as a Failed
row with the error in the Value column.
- If you've configured a write bound (Min/Max) on that register, a rejected write logs
"Write rejected: value ... is outside the configured write bound [...]". This applies no
matter whether the write came from the Address Table, a Tag, or a Script.
- Discrete Inputs and Input Registers are read-only in the Modbus spec itself - no amount of
configuration in ModbusLens will make them writable, because the device won't accept it either.
A tag shows ERROR in Tags Monitoring
- Check the tag's Count matches its Format - 32-bit formats (U32/S32/F32, and
their
_SWAP variants) need a count that's a multiple of 2; 64-bit formats
(U64/S64/F64, and their _SWAP variants) need a multiple of 4.
- Check the address is actually valid on the device - some devices have gaps in their register
map that return an exception rather than a value.
- One failing tag no longer stops the rest of the list from updating, so if only one row shows
ERROR while the others keep ticking, the problem is specific to that tag's configuration, not the
connection.
- If every tag shows ERROR at once, monitoring will auto-stop after a few consecutive
failed polls - that's treated as a lost connection rather than a tag problem. ModbusLens will
retry the connection itself automatically (see the next entry); you shouldn't need to do
anything unless it can't recover.
Status shows "Reconnecting..." and it's taking a while
This is expected - once connected, ModbusLens watches the connection and auto-retries with
increasing delays (2s, 4s, 8s... capped at 30s) if it drops, rather than requiring a manual
reconnect. If Tags monitoring was running and stopped because every tag failed at once, it
resumes automatically the moment the connection recovers. If it's still stuck reconnecting after
a while, the underlying cause is the same as an initial connection failure - work through the
TCP or Serial checklist above (device power, cabling, IP/port, Unit ID). Clicking
Disconnect stops the retry loop entirely, if you want to give up on it.
"Duplicate Address" or "Overlapping Ranges" warning
Two tags of the same Modbus type (e.g. two Holding Register tags) are pointing at the same or
overlapping addresses. This is usually a copy-paste mistake when building a large tag list -
check the Address column against your register map.
"Server Already Running"
Only one Server tab can be active at a time, across every open ModbusLens window, because the
underlying Modbus library only supports one active server per process. Stop the other one first.
A script won't Compile
Compile errors describe exactly what's wrong and where, for example:
REPEAT without matching END / END without matching REPEAT - a
REPEAT block wasn't closed, or an extra END has nothing to close.
unrecognized command: ... - a typo in a command keyword, or a command used
outside where it's valid (e.g. REPEAT/IF nested somewhere they're not allowed).
invalid address: ... / address ... out of range (0-65535) - the
address after a type (COIL/DI/HR/IR) isn't a valid number in range.
A script won't Run (compiles fine, fails immediately)
not connected to a Modbus server - the script is set to Client-target but there's
no active connection. Connect first, or switch Target to Server-target to test against the
Server tab instead.
Server is not running - start it on the Server tab first - the opposite case:
Target is Server-target but the Server tab hasn't been started.
... cannot be written to a client connection - a WRITE targeted a
Discrete Input or Input Register, which are read-only by the Modbus spec.
read failed for HR 12 (or similar) - the read inside an expression failed against
the live device; check the address is valid, the same way you would for a Tags tab ERROR.
REPEAT UNTIL exceeded the 1000000-iteration limit without the condition becoming true
- the condition never became true; double-check the address/comparison, or that the device is
actually changing the value you're waiting on.
Network Discovery isn't finding a device, or feels slow
Diagnostics > Network Discovery & Diagnostics scans the local network and checks
which devices respond to Modbus - useful when you know a PLC is on the subnet but don't know its
current IP. It combines a few techniques:
- ARP-based discovery - finds devices on the local subnet without needing to know their
IP addresses in advance. This is the fast path, and it's what requires Npcap (below).
- Modbus detection - probes discovered devices to see which ones answer Modbus requests,
so you're not guessing which IP is the PLC.
- Device filtering - "Show only Modbus devices" hides everything else from the list.
Selecting a discovered device fills in its IP/port for you in Connection Settings.
Advanced discovery (the fast ARP path) requires Npcap. Without it, ModbusLens falls back
to a ping-based scan, which is slower and misses devices on networks that block ICMP - if scanning
feels slow or misses a device you know is there, this is almost always why. Install Npcap with
WinPcap compatible mode enabled during setup, then restart ModbusLens - see the README's
Notes section for the download link and exact install options.
The scan range is sized to the selected interface's actual subnet mask, not always a flat /24
- on a /25 or smaller it only probes that real range, and on anything wider than /24 (e.g. a
VPN-routed /16) it's capped to the /24 containing that interface's own IP, since probing tens of
thousands of addresses one TCP connect at a time isn't practical. If a device you know exists on
a wider VPN subnet doesn't turn up, it may simply be outside that /24 - enter its IP directly
instead of relying on discovery. The progress bar shows the specific IP currently being probed,
not just a percentage, so you can see exactly where a scan is up to.
"Check for Updates" fails or times out
The Updates tab in Help > About queries GitHub directly and needs outbound internet access.
If it can't reach GitHub (offline, a proxy, or a firewall blocking it), it reports the failure
rather than hanging, and gives you a direct link to the Releases page to check manually.
Tips & Safety
- ModbusLens can both read and write live Modbus values. An incorrect write to a
production device can cause unexpected motion, changed setpoints, or bypassed safety logic.
Know the device's register map and have authorization before writing to anything real.
- Use Server Mode as a local practice target: start a server in one window, connect to
it from another (or a second ModbusLens instance via a New Connection Window), and try things out
- including a full Script or Tag list - before pointing at real equipment.
- For anything that writes automatically and repeatedly (a Script, or a Tag in Write mode with
monitoring active), consider setting a write bound (Min/Max) on the target register first - see
the Address Table topic. It costs nothing when everything is working, and catches a typo the
moment it would otherwise reach the device.
- If something isn't behaving as expected - wrong values, failed writes, a script that won't
run - check Troubleshooting before assuming it's a device problem; most of the common
causes are configuration mismatches on this end (addressing mode, word order, Unit ID) rather
than a fault on the device.