ModbusLens

Documentation

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

  1. 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.
  2. Click Connect. The status indicator on the left turns green when connected.
  3. 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.

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

  1. Pick a Function (Read Coils, Read Holding Registers, Write Single Register, etc.).
  2. Set Start Address and Count.
  3. 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

  1. Set Server Address (usually 0.0.0.0 to accept connections on any network interface), Port, and Unit ID.
  2. 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|OFFWrite 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> ... ENDLoop 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.