OmronEip 0.2.4

dotnet add package OmronEip --version 0.2.4
                    
NuGet\Install-Package OmronEip -Version 0.2.4
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OmronEip" Version="0.2.4" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OmronEip" Version="0.2.4" />
                    
Directory.Packages.props
<PackageReference Include="OmronEip" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add OmronEip --version 0.2.4
                    
#r "nuget: OmronEip, 0.2.4"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package OmronEip@0.2.4
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=OmronEip&version=0.2.4
                    
Install as a Cake Addin
#tool nuget:?package=OmronEip&version=0.2.4
                    
Install as a Cake Tool

OmronEip Library Guide

This guide teaches you how to read and write variables on an Omron NX or NJ controller from a C# program using the OmronEip library. It is written for someone who may be new to C#. Every concept is explained before it is used, and every code snippet follows the patterns that were verified against real hardware.

The guide has two parts. The Basics part gets you working and explains the ideas in plain language. The Advanced part is the complete reference for every property and method, plus the deeper protocol topics. Basic sections link ahead to their advanced counterparts when there is more to know.

Table of contents

Part one, the basics

Part two, advanced

1. What this library is

The OmronEip library lets your .NET program talk to an Omron NX or NJ controller over an ordinary Ethernet network, using EtherNet IP explicit messaging. Nothing is installed on the controller. Your program can read the value of any published PLC variable by name, write new values, read a variable on a schedule at the rate you choose, and write values repeatedly, for example if a heartbeat is needed.

Reads come in a few forms because programs need data in different situations, and each is covered in detail later. A one shot read fetches the value right now, and it exists in an async form that keeps an application responsive while it waits and a blocking form for simple sequential programs. A subscription is a repeating read, the library reads the variable over and over on a schedule it maintains for you, for values you need continuously.

Writes follow the same idea. A plain write sends a value of the matching type from your code. A verified write reads the value back afterward to confirm the controller stored it. A text write takes whatever a person typed, a number, a list, or JSON, and figures out the correct format by asking the controller what the variable is. And a repeating write sends a value on a schedule, which is how a heartbeat is done. All of these are used through one class called Omron, and each one is explained in its own section.

2. Setting up the variables in Sysmac Studio

The controller only exposes variables you explicitly publish, so this is the one piece of setup on the PLC side. In Sysmac Studio open the global variable table under Programming, Data, Global Variables, find the variable you want your program to reach, and set its Network Publish column to Publish Only. Then transfer the project to the controller. That is the entire procedure. Here is the variable table used throughout this guide, with every test variable published.

The Sysmac Studio global variable table with Network Publish set to Publish Only for each test variable

These particular variables are only examples. You can publish any variable of any data type, arrays of any size, and structures of your own design, and everything in this guide works the same way on them. Only global variables can be published, variables local to a program cannot, and names are matched exactly including capitalization.

The examples in this guide also use two custom structures, defined under Data Types. The first, sMyDataType1, contains a BOOL array, a ULINT, a REAL, a STRING, and notably a member called Test_Custom whose type is the second structure, so it is a structure inside a structure. The second, sMyDataType2, contains four single BOOL members, a BOOL array declared with a non zero starting index, ARRAY[4..15], and an LREAL array. Two published variables use them, StructTest1 of type sMyDataType1 and StructTest2 of type sMyDataType2.

The Sysmac Studio data type definitions for sMyDataType1 and sMyDataType2

When a structure variable is published, all of its members become reachable, including the nested structure and everything inside it. You will see every one of these shapes read and written in section 11.

3. Why the library uses multiple connections and separate tasks

Before any code, it helps to understand one behavior of the controller itself, because it explains the shape of everything else. This was measured on real NX hardware, not assumed.

An Omron controller can accept several network connections at once, but it does not serve them at the same time. It takes turns. If two connections both ask for data, the controller answers one, then the other, then the first again. Opening more connections therefore never makes anything faster. What separate connections give you is isolation. Work on one connection can never delay work on another beyond the controller taking its turn, and a long transfer on one channel never blocks a small urgent request waiting behind it on the same channel.

The library is built around that fact. When you connect, it quietly opens two channels. One is reserved for subscriptions, the repeating reads that must keep at or below a time interval. The other handles your one shot reads and writes. That way, pressing a button in your program to write a value never has to wait behind a big cyclic array read, and the cyclic rhythm is never disturbed by other read and write processes.

The same idea extends to what this guide calls tasks. If you monitor several variables and one of them is a large slow structure while another is a small fast array, putting them on separate connections means the slow one can never stretch the fast one beyond its target. A practical consequence: when your program monitors several variables and one of them is large and slow while another is small and fast, giving each its own connection means the slow one can never delay the fast one. The trade off to keep in mind is that all connections still share the controller total capacity, because of the turn taking, so isolation is about protecting schedules from each other, not about adding speed.

Note: One more measured behavior shapes the defaults. It is possible to send a second request before the answer to the first has come back, a technique called pipelining, and on paper it sounds like it should be faster. On the NX502 it measured slower. The controller works through one request at a time, and anything sent early just sits in a queue on the controller, adding overhead. So this library sends one request at a time by default, and that setting should only be changed after measuring your own controller. Details are in section A10.

4. Concepts

Variables and tags

The PLC program has named storage boxes called variables, sometimes called tags. The library reads and writes them by their exact name, with the same spelling and capitalization as in Sysmac Studio. Only variables marked with the Network Publish attribute in the global variable table are visible from the network. That is a deliberate safety feature of the controller, an explicit list of what outsiders may touch.

Data types

Every PLC variable has a type, and when reading you tell the library which .NET type you expect. The pairs must match.

PLC type What it holds .NET type you use
BOOL true or false. Travels as 2 bytes on the network; arrays of BOOL inside structures are packed 16 to a word bool
SINT small whole number, minus 128 to 127, 1 byte sbyte
INT whole number to about 32 thousand, 2 bytes short
DINT whole number to about 2.1 billion, 4 bytes int
LINT very large whole number, 8 bytes long
USINT, UINT, UDINT, ULINT the same four sizes with no negatives, so the top of each range doubles byte, ushort, uint, ulong
REAL decimal number, about 7 digits, 4 bytes float
LREAL decimal number, about 15 digits, 8 bytes double
STRING text. STRING[N] reserves N bytes including the terminator, so it holds up to N minus 1 characters string
BYTE, WORD, DWORD, LWORD bundles of 8, 16, 32 or 64 individual bits, flags and status registers, 1, 2, 4 or 8 bytes raw value; read and written as hexadecimal text, most significant byte first, exactly as it appears in Sysmac
TIME a duration, stored as 64 bit nanoseconds, 8 bytes long nanoseconds
DATE, DATE_AND_TIME a calendar date or full timestamp, 64 bit nanoseconds since 1970, 8 bytes DateTime
TIME_OF_DAY time within a day as nanoseconds, 8 bytes ulong nanoseconds
ARRAY OF something a row of values of one type float[], double[], bool[] and so on
a structure a bundle of named members a dictionary, or your own class, see A7

Async and sync

Talking over a network takes a few milliseconds, and the question is what your program does while it waits. A sync call stops at that line until the answer arrives, like waiting at a counter for your order. An async call hands the wait off, your program stays free, and it resumes when the answer arrives. Async methods end in the word Async and you put the keyword await in front of them. Every operation in this library exists in both flavors. In a simple console tool either is fine. In a program with a window, use async for anything a button does, because a sync call freezes the whole window while it waits.

Disposing

Connections and subscriptions hold real resources, an open network socket, a running schedule, a precision timer. Disposing is how you say you are done, shut it down and release everything. In C# the pattern is the await using keyword, which guarantees disposal happens automatically when the variable goes out of scope, even if an error occurs. Every example in this guide uses it. The library disposal is complete and immediate, and it is tested by starting and stopping a subscription one hundred times while proving the program memory does not grow. The full account of what each object holds, disposal order, and the leak testing is section A12.

5. Your first program

Here is the smallest complete example, shown the way it would appear in a console application. Replace the address with your controller.

using OmronEip;

await using var plc = await Omron.ConnectAsync("192.168.250.1");

float speed = await plc.ReadAsync<float>("MachineSpeed");

Console.WriteLine($"Machine speed is {speed}");

Three things are happening. The first line connects and guarantees a clean close at the end. The second reads a variable declared as REAL in the PLC, so the expected .NET type is float. The await keyword makes it an async call, and the program continues when the answer arrives, typically within a few milliseconds.

6. Connecting and the settings

The one line connect uses defaults that were measured to be the optimum on real controllers.

await using var plc = await Omron.ConnectAsync("192.168.250.1");

You can pass options as a second argument when you need to change something. This example shows the two you are most likely to touch. RequestTimeout is how long any single request will wait for the controller to answer before giving up and reporting a timeout error. The default is ten seconds, and setting it shorter, as here, makes a dead network show up as an error faster. The complete list of options with every default and the reason behind it is in section A2.

await using var plc = await Omron.ConnectAsync("192.168.250.1", new OmronOptions
{
    RequestTimeout = TimeSpan.FromSeconds(5),
    UseConnectedMessaging = false
});

The UseConnectedMessaging switch picks between the two transport styles of the protocol, unconnected messaging called UCMM which is the default, and connected messaging called Class 3. On a direct cable to the controller UCMM is faster, which is why it is the default. What the two styles actually are, how the connection negotiation works, and when Class 3 earns its keep are explained in section A1.

The connection heals itself. If the cable is pulled or the controller restarts, the library keeps retrying in the background and re establishes everything when the controller returns. You can watch it happen if you want to log it, through two events. The plus equals syntax below subscribes your function to an event, and the library then calls that function automatically every time the event occurs, here once when the connection is lost and once when it comes back. This same subscribe with plus equals pattern applies to every event in the library.

plc.Disconnected += error => Console.WriteLine($"Lost the PLC: {error?.Message}");
plc.Reconnected  += ()    => Console.WriteLine("PLC is back.");

Keeping idle connections alive

Omron NX and NJ controllers close a connection that sits idle for about 120 seconds. This matters even when you are reading constantly, because of a detail worth knowing. Each connection you open actually uses two channels under the hood, one dedicated to subscriptions and one for your one shot reads and writes. If you only subscribe, the one shot channel sits idle, and the controller closes it at the 120 second mark, which shows up as a connection dropped and immediately reconnected on a steady two minute rhythm. The reads never miss because the subscription channel stays busy, but the log fills with drops.

The library prevents this for you automatically. If a channel sends nothing for the keep alive interval, thirty seconds by default, the library sends a tiny keep alive message that resets the controller timer, using the same send path as normal traffic so it never interferes with your reads or writes. A channel that is busy with real traffic is left alone, since its own messages already reset the timer. You do not have to do anything to get this, but you can change the interval, or turn it off, through the options.

await using var plc = await Omron.ConnectAsync("192.168.250.1", new OmronOptions
{
    KeepAliveInterval = TimeSpan.FromSeconds(30)   // the default; TimeSpan.Zero turns it off
});

7. Reading values

Single values, arrays, and whole structures all read the same way. The type parameter tells the library what you expect back.

float speed    = await plc.ReadAsync<float>("MachineSpeed");
bool running   = await plc.ReadAsync<bool>("Running");
string batch   = await plc.ReadAsync<string>("BatchName");
float[] curve  = await plc.ReadAsync<float[]>("TestR");

float syncRead = plc.Read<float>("MachineSpeed");

A structure reads most easily as a dictionary, a lookup table from member name to value. Ask for object and the library returns the natural shape. Structures inside structures come back as dictionaries inside dictionaries, to any depth, and the layout always matches your controller because the library discovers it from the controller itself.

var data = (Dictionary<string, object?>) await plc.ReadAsync<object>("StructTest1");
float r  = Convert.ToSingle(data["Test_Real"]);

var nested = (Dictionary<string, object?>) data["Test_Custom"]!;
bool b1 = (bool) nested["Bool1"]!;

You can also address inside a variable with the same dotted and bracketed names Sysmac uses. Reading one member is one tiny network message, while reading a large structure can be hundreds, so reach for the member when the member is all you need.

float r  = await plc.ReadAsync<float>("StructTest1.Test_Real");
double v = await plc.ReadAsync<double>("Test1[42]");

ReadAsync with object, letting the library figure out the type for you

Every read so far stated the expected type up front. You can also leave the type out entirely by writing ReadAsync<object>, the same read method you already know with object as the type parameter, nothing new to learn. The library asks the controller what the variable is and hands back the value in its natural form, a REAL arrives as a float, a ULINT as a ulong, an array as an array, a date as a DateTime, and a structure as a dictionary. No type parameter, no guessing.

object? value = await plc.ReadAsync<object>("StructTest1.Test_ULINT");
Console.WriteLine(value);        // a ulong, straight from the controller type

This is the mode to reach for when your program does not know the type ahead of time, a diagnostic screen where a person types any variable name, or a tool working from the discovery list of section 13. C# pattern matching then lets you branch on whatever came back.

object? anything = await plc.ReadAsync<object>(tagName);

switch (anything)
{
    case float f:
        Console.WriteLine($"a REAL: {f}");
        break;
    case double[] arr:
        Console.WriteLine($"an LREAL array with {arr.Length} values");
        break;
    case Dictionary<string, object?> st:
        Console.WriteLine($"a structure with {st.Count} members");
        break;
    default:
        Console.WriteLine(anything);
        break;
}

The write side has the same idea in the text write of section 10, which never asks you for a type either, it parses whatever text you give against the type the controller reports. Between the two, a program can read and write variables it has never heard of.

Section 11 walks through reading and writing every one of these shapes, single values, array elements, whole arrays, structure members, whole structures, nested structures and arrays inside structures, with the typed form and the text form side by side. And if a dictionary feels clumsy for your structures, the library can map them onto your own C# classes with named, typed properties, which section A7 shows.

8. Reading repeatedly with a guaranteed maximum data age

This is the most important capability in the library, and the reason it exists. Most PLC client code you will find works the obvious way, a timer loop that fires every so often and reads. The problem with that approach is that it makes a promise about when reads start, when what you actually care about is how old the data on your screen is. This library turns the requirement around. You state the maximum age the data is ever allowed to reach, and the library takes responsibility for meeting it.

await using var sub = await plc.SubscribeRead<float[]>(
    "TestR",
    maxAge: TimeSpan.FromMilliseconds(50),
    onValue: values =>
    {
        Console.WriteLine($"Got {values.Length} values, first is {values[0]}");
    });

Read that call as a contract. TestR is an array of 1500 REAL values, six thousand bytes, and the contract says a copy of it that is at most 50 milliseconds old must always be available. Here is how the library honors it. The controller answer time varies, it usually serves this read in a handful of milliseconds but every so often takes longer because it is busy with its own control program between task cycles. If you polled exactly every 50 milliseconds, each slow answer would arrive late and the age would breach the limit. So the library polls at roughly half the limit, about every 25 milliseconds, an old signal processing principle, sample twice as fast as you need. When one read runs slow, the reads around it were fast, and at any moment the newest copy on hand is essentially always inside the limit.

Two more safeguards run underneath, both born from real hardware testing. Before starting, the library times a few reads of your actual tag and refuses to poll faster than the tag can physically be read, so if you ask for a rate the tag simply cannot deliver, the library settles at the fastest rate that actually works instead of endlessly attempting one it can never meet. And it never lets reads pile up behind each other, if a poll moment arrives while the previous read is still in flight, it skips rather than queues, because a backlog only makes every later read older. Notice everything you did not write: no timer, no loop, no retry logic, no reconnect handling, no age arithmetic. One call states the requirement, the library owns the mechanics.

Several variables at different rates, the multiple task pattern

Real applications rarely watch one variable. Suppose you need a 1500 element REAL array every 50 milliseconds, and at the same time three larger LREAL arrays at gentler rates, without any of the four ever missing its period. The pattern that achieves it gives each schedule its own connection, its own task in the sense of section 3.

var host = "192.168.251.1";

var plcR = await Omron.ConnectAsync(host);
var plc1 = await Omron.ConnectAsync(host);
var plc2 = await Omron.ConnectAsync(host);
var plc3 = await Omron.ConnectAsync(host);

await using var subR = await plcR.SubscribeRead<float[]>("TestR",
    TimeSpan.FromMilliseconds(50),  v => HandleTestR(v));

await using var sub1 = await plc1.SubscribeRead<double[]>("Test1",
    TimeSpan.FromMilliseconds(250), v => HandleTest1(v));

await using var sub2 = await plc2.SubscribeRead<double[]>("Test2",
    TimeSpan.FromMilliseconds(250), v => HandleTest2(v));

await using var sub3 = await plc3.SubscribeRead<double[]>("Test3",
    TimeSpan.FromMilliseconds(500), v => HandleTest3(v));

Why four connections instead of four subscriptions on one. Remember from section 3 that the controller serves connections by taking turns, so extra connections add no total speed, what they add is isolation. Test3 is five thousand LREAL values, forty thousand bytes, a transfer of dozens of messages. If it shared a channel with TestR, every Test3 cycle would occupy the channel for long stretches and TestR would wait its turn behind it, stretching the tight 50 millisecond schedule. On its own connection, TestR only ever waits for the controller to finish one small message of someone else's turn, never for a whole large transfer. Each subscription keeps its rhythm because no other schedule can stand in front of it.

The honest accounting still applies. All four connections draw from the controller total serving capacity, so isolation protects the schedules from each other but cannot manufacture bandwidth. Add up what this example moves and you see how much room there is: TestR at six thousand bytes twenty times a second is 120 thousand bytes per second, Test1 adds 32 thousand, Test2 adds 48, Test3 adds 80, roughly 280 thousand bytes per second sustained. If you subscribe to variables that can tolerate waiting, sharing is fine too, several subscriptions on one Omron object simply take turns on that channel.

Getting the values out, push and pull

The function you pass as onValue is the push style, the library calls you with every new value, from a background worker thread so your program is never interrupted. In a windowed application that thread detail matters, only the interface thread may touch controls, so a callback that updates the screen hands the update across, in WPF with Dispatcher.Invoke. The full story of threads and waiting is section 9, next.

The onValue parameter is actually a convenience for a real .NET event on the subscription called ValueReceived, and you can use the event directly instead. The library fires it at the moment each read completes with new data, which makes it the answer to a natural question, is there an event when the data arrives, yes, this is it. Using the event form buys you two things the parameter cannot do. You can attach several listeners, so one updates the screen while another logs to a file and a third feeds an analysis queue, and every one of them receives every value. And you can attach or detach listeners while the subscription is already running, with the ordinary plus equals and minus equals syntax. There is also an Error event that fires with the exception whenever a read fails. A failed read does not stop the schedule, the subscription keeps going and reports the failure to anyone listening.

await using var sub = await plc.SubscribeRead<float[]>("TestR",
    TimeSpan.FromMilliseconds(50));

sub.ValueReceived += values => UpdateDisplay(values);
sub.ValueReceived += values => LogToFile(values);

sub.Error += ex => Console.WriteLine($"Read failed: {ex.Message}");

The pull style is asking whenever it suits you. TryGetLatest hands over the newest copy the subscription already holds together with its age, it never blocks and never touches the network, the value was already fetched by the schedule.

if (sub.TryGetLatest(out float[] values, out TimeSpan age))
{
    Console.WriteLine($"Newest copy is {age.TotalMilliseconds:0} ms old");
}

The subscription also keeps its own statistics, so you can check how well it is keeping up rather than taking it on faith. Ask FractionWithinMs with your limit and it reports what fraction of arrivals met it over the whole run, ask RecentFractionWithinMs for the same over just the last few hundred, the number that reacts within seconds if conditions change. With the default settings these sit at or near one hundred percent. The complete reference for every statistic is section A4. When you are done, dispose the subscription, the schedule stops immediately and completely.

9. Async and sync, every call in two flavors

Every read and every write in this library exists twice, an async version whose name ends in Async that you call with await, and a sync version without the suffix. They do exactly the same work on the network. The difference is entirely about what your program does during the few milliseconds the request is traveling, and choosing correctly is the difference between a responsive application and a frozen one, so this section takes it slowly.

What actually happens during a call

A read is a message to the controller and an answer back, typically two to five milliseconds on a direct cable, longer on a routed network, and up to your configured timeout when something is wrong. Your program has to spend that time somehow.

The sync version blocks. The thread that called it stops at that line, doing nothing, until the answer arrives, like standing at a counter waiting for your order. Then the value is returned and the next line runs.

float speed = plc.Read<float>("MachineSpeed");
plc.Write("Setpoint", 12.5f);
string result = plc.WriteText("TestR[0]", "3.14");

The async version hands the wait off. The method pauses at the await, the thread is released to do other work, and when the answer arrives the method resumes right where it paused, with the value. Nothing anywhere stood still.

float speed = await plc.ReadAsync<float>("MachineSpeed");
await plc.WriteAsync("Setpoint", 12.5f);
string result = await plc.WriteTextAsync("TestR[0]", "3.14");

Notice the pairs are line for line identical apart from the suffix and the await keyword, same parameters, same return values, same errors. Reads pair Read with ReadAsync. Writes pair Write with WriteAsync and WriteText with WriteTextAsync. The one exception is the verified write, which exists only as WriteVerifiedAsync and WriteTextVerifiedAsync, because its write then read back confirmation is inherently a multi step network operation. Nothing about what reaches the controller changes.

One related point worth making explicit. People sometimes look for an event that fires when the data of a one shot ReadAsync arrives, and there is none, on purpose, because the await is the arrival notification. The task completes at exactly the moment the answer lands, so the line after the await runs precisely when the data arrives, holding the value. When you find yourself wanting arrival events over and over for the same variable, that is the signal you want a subscription instead, whose ValueReceived event in section 8 is exactly that, arrival notification plus the schedule and the age guarantee around it.

When each one is the right choice

Sync is the natural fit when blocking costs nothing, a console utility, a script, a sequential test procedure, a background worker thread that exists to do this work anyway. The code reads plainly top to bottom and there is no ceremony.

Console.WriteLine("Reading recipe...");
var recipe = (Dictionary<string, object?>) plc.Read<object>("StructTest1");
plc.Write("StructTest1.Test_Real", 42.5f);
Console.WriteLine("Done.");

Async is required, not merely preferred, anywhere a blocked thread would be felt. The clearest case is a windowed application. A window is drawn and its buttons respond because one special thread, the interface thread, is free to process events. Call a sync read in a button handler and that thread stands at the counter, the window stops repainting, buttons stop responding, and if the cable happens to be unplugged it stays frozen for the entire ten second timeout. The same operation with await keeps the interface thread free the whole time, the window stays alive, and the handler resumes when the value arrives.

private async void ReadBtn_Click(object sender, RoutedEventArgs e)
{
    ReadBtn.IsEnabled = false;
    float speed = await plc.ReadAsync<float>("MachineSpeed");
    SpeedText.Text = speed.ToString("0.0");
    ReadBtn.IsEnabled = true;
}

If you ever must call sync code from a window, wrap it so the wait happens on a worker thread instead, await Task.Run with the sync call inside. That is the bridge between the two worlds, useful when integrating older code.

How the repeating reads relate

The subscriptions of section 8 are a third pattern, the library drives the schedule and your onValue callback is invoked from a background thread. Inside that callback you are not on the interface thread, which is why screen updates go through Dispatcher.Invoke, and you should keep the callback quick, hand heavy work elsewhere, because the next value is on its way. Starting a subscription is itself an async call you await once, and after that the library does the calling.

Using a subscription value in the main program or another thread

A question that comes up quickly with subscriptions: the callback runs on a background thread, so how does the rest of the program get at the values. The answer starts with where the value lives. The subscription stores exactly one thing, the newest value together with the moment its read completed, guarded by a lock. That stored copy is what TryGetLatest and Latest return, and because of the lock they are safe to call from any thread at any time, including while a new value is arriving. The lock also guarantees that whichever thread receives the reference sees the array contents fully written, never a half updated state.

So the simplest pattern needs no callback at all. The main program just asks for the newest copy whenever it is convenient, on its own schedule, and the subscription quietly keeps that copy young in the background.

await using var sub = await plc.SubscribeRead<float[]>("TestR",
    TimeSpan.FromMilliseconds(50));

while (running)
{
    if (sub.TryGetLatest(out float[] values, out TimeSpan age))
        UpdateDisplay(values, age);

    await Task.Delay(100);
}

Handing values to another thread is just as safe, because of how delivery works. Every update is a freshly decoded array, and the library never writes into an instance again after delivering it, the next poll allocates a new one. That means the array your callback receives is yours to keep, to queue, or to pass anywhere. A concurrent queue makes a clean bridge to a worker thread that processes at its own pace.

var queue = new ConcurrentQueue<float[]>();

await using var sub = await plc.SubscribeRead<float[]>("TestR",
    TimeSpan.FromMilliseconds(50),
    onValue: values => queue.Enqueue(values));

while (processing)
{
    while (queue.TryDequeue(out var snapshot))
        Analyze(snapshot);

    await Task.Delay(10);
}

One rule keeps all of this safe: treat received values as read only. The instance handed to your callback is the same instance Latest and TryGetLatest hand to everyone else until the next update replaces it, so the library will never change it under you, but if your own code modifies it, every other holder of that reference sees the edits. When you need a version you can change, copy it first, for a float array that is simply values.ToArray(). Two smaller facts round out the picture. Callbacks for one subscription never overlap, the scheduler skips a tick rather than stacking reads, so onValue runs one at a time in arrival order and needs no locking of its own. And thread safe is not the same as interface safe, a callback that updates a window control still hands the update across with Dispatcher.Invoke, as covered above.

One rule summarizes the whole section. Await in anything with a window or a server request, block only where a stopped thread is harmless, and never block the thread that owns the screen.

10. Writing values

Writing from code looks just like reading, you pass the tag name and a value, and the value type must match the PLC declaration, and there is a verified flavor that writes, reads back, and confirms the controller stored exactly what you sent, which matters when the PLC program itself might be overwriting the variable every scan.

await plc.WriteAsync("MachineSpeed", 12.5f);
await plc.WriteAsync("PartCount", (short)100);
await plc.WriteAsync("TestR", new float[] { 1.1f, 2.2f, 3.3f });
await plc.WriteVerifiedAsync("MachineSpeed", 12.5f);

await plc.WriteAsync("StructTest1.Test_Real", 99.5f);
await plc.WriteAsync("Test1[42]", 3.14159);

Often the value starts life as text, typed by a person or read from a file. The text writing methods take a string and parse it correctly by asking the controller what type the tag is, so you never state the type. They return a plain language summary of what was written.

await plc.WriteTextAsync("MachineSpeed", "12.5");
await plc.WriteTextAsync("Running", "true");
await plc.WriteTextAsync("TestR", "1.1, 2.2, 3.3");
await plc.WriteTextAsync("TestR", "[1.1, 2.2, 3.3]");

string summary = await plc.WriteTextAsync("StructTest1",
    "{ \"Test_Real\": 1.5 }");
Console.WriteLine(summary);

That last call is the important one. When you write a structure as JSON you only include the members you want to change, and every member you leave out keeps its current value in the PLC. The library achieves this by reading the structure, merging your members in, and writing it back whole, because the protocol can only write structures whole and a naive partial write would silently zero everything you did not mention. The JSON parsing is deliberately forgiving. Member names without quotation marks, capitalized True and False, and a missing comma between lines are all accepted, because that is how people actually type. The full parsing rules are in section A8.

11. Every shape, worked read and write examples

This section walks through every shape of data you can touch, using the exact variables and structures from section 2, showing the direct typed form and the text form side by side for each. Direct typed calls are what you use inside a program where the values live in variables. Text calls are what you use when the value starts as a string, typed by a person or loaded from a file, and they work for every shape including partial structure updates.

A single variable

float r = await plc.ReadAsync<float>("StructTest1.Test_Real");

await plc.WriteAsync("StructTest1.Test_Real", 99.5f);
await plc.WriteTextAsync("StructTest1.Test_Real", "99.5");

A single array element

Address the element with its index in brackets. TestR is ARRAY[0..1499] OF REAL, so elements are float.

float one = await plc.ReadAsync<float>("TestR[42]");

await plc.WriteAsync("TestR[42]", 3.14f);
await plc.WriteTextAsync("TestR[42]", "3.14");

An entire array

The text form takes comma separated values or a JSON array, whichever you have. Both produce the same write.

float[] all = await plc.ReadAsync<float[]>("TestR");

await plc.WriteAsync("TestR", new float[] { 1.1f, 2.2f, 3.3f });
await plc.WriteTextAsync("TestR", "1.1, 2.2, 3.3");
await plc.WriteTextAsync("TestR", "[1.1, 2.2, 3.3]");

A structure member

Dotted names reach members, and nesting goes as deep as the structure does. Test_Custom is itself a structure, so its members take two dots.

ulong u  = await plc.ReadAsync<ulong>("StructTest1.Test_ULINT");
bool  b1 = await plc.ReadAsync<bool>("StructTest1.Test_Custom.Bool1");

await plc.WriteAsync("StructTest1.Test_ULINT", 9UL);
await plc.WriteTextAsync("StructTest1.Test_Custom.Bool1", "true");

An entire structure

Reading as object returns the natural shape, a dictionary of member name to value, with nested structures as nested dictionaries. Writing a whole structure from text takes a JSON object. This is the complete JSON for StructTest2, exactly as the library would round trip it.

var st2 = (Dictionary<string, object?>) await plc.ReadAsync<object>("StructTest2");
await plc.WriteTextAsync("StructTest2", """
{
  "Bool1": false,
  "Bool2": false,
  "Bool3": false,
  "Bool4": false,
  "Bool_5_to_15": [
    false, false, false, false, false, false,
    false, false, false, false, false, false
  ],
  "Array_LREAL": [
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0
  ]
}
""");

A structure inside a structure

StructTest1 contains Test_Custom, a full sMyDataType2. Reading the whole of StructTest1 brings the nested structure along as a dictionary inside the dictionary, and the full JSON write nests an object inside the object. Here is the complete JSON for StructTest1.

await plc.WriteTextAsync("StructTest1", """
{
  "Test_Array_BOOL": [
    false, false, false, false, false,
    false, false, false, false, false
  ],
  "Test_ULINT": 0,
  "Test_Custom": {
    "Bool1": false,
    "Bool2": false,
    "Bool3": false,
    "Bool4": false,
    "Bool_5_to_15": [
      false, false, false, false, false, false,
      false, false, false, false, false, false
    ],
    "Array_LREAL": [
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
      0, 0, 0, 0, 0, 0, 0, 0, 0, 0
    ]
  },
  "Test_Real": 0,
  "Test_String": ""
}
""");

You can also read or write the nested structure on its own by its path.

var custom = (Dictionary<string, object?>) await plc.ReadAsync<object>("StructTest1.Test_Custom");

await plc.WriteTextAsync("StructTest1.Test_Custom", "{ \"Bool2\": true }");

Partial JSON, missing members keep their previous values

You never have to send the whole JSON. Include only the members you want to change, and every member you leave out keeps its current value in the controller. This single line changes one member of StructTest1 and leaves the other four untouched.

string summary = await plc.WriteTextAsync("StructTest1", "{ \"Test_Real\": 42.5 }");
Console.WriteLine(summary);

The summary spells out what happened, updated one member, four kept their previous value. Partial updates work at any depth, so this next call sets one flag inside the nested structure while preserving everything else in both the outer and the inner structure.

await plc.WriteTextAsync("StructTest1",
    "{ \"Test_Custom\": { \"Bool1\": true } }");

An array inside a structure

A structure member that is itself an array, like Bool_5_to_15 here, reads by its dotted path like any other member. For writing it there are two ways, and both work: write the member directly by its path with a text write, or include just that member in a partial JSON write on the parent structure. The parent form is shown below.

bool[] flags = await plc.ReadAsync<bool[]>("StructTest2.Bool_5_to_15");

await plc.WriteTextAsync("StructTest2", """
{ "Bool_5_to_15": [ true, false, false, false, false, false,
                    false, false, false, false, false, false ] }
""");

await plc.WriteTextAsync("StructTest2",
    "{ \"Array_LREAL\": [1.5, 2.5, 3.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }");

Note that Bool_5_to_15 is declared as ARRAY[4..15], twelve elements starting at index four. When it arrives as an array in your program it is simply twelve values, and when you write it through the parent you supply all twelve. Writing an individual element of an array inside a structure by bracketed path is the one thing not supported, because of that non zero starting index, so change single flags by writing the member array through the parent as shown.

Every elementary type, read and written in its correct format

Here is the whole elementary type family in one place, each read into its matching .NET type and each written in the text format the parser expects. The integer family and the reals are plain numbers in both directions.

sbyte  tiny   = await plc.ReadAsync<sbyte>("MySint");
short  small  = await plc.ReadAsync<short>("MyInt");
int    medium = await plc.ReadAsync<int>("MyDint");
long   big    = await plc.ReadAsync<long>("MyLint");
byte   ub     = await plc.ReadAsync<byte>("MyUsint");
ushort us     = await plc.ReadAsync<ushort>("MyUint");
uint   ud     = await plc.ReadAsync<uint>("MyUdint");
ulong  ul     = await plc.ReadAsync<ulong>("MyUlint");
float  r      = await plc.ReadAsync<float>("MyReal");
double lr     = await plc.ReadAsync<double>("MyLreal");

await plc.WriteAsync("MyDint", 42);
await plc.WriteAsync("MyLreal", 3.14159);
await plc.WriteTextAsync("MyDint", "42");
await plc.WriteTextAsync("MyLreal", "3.14159");

BOOL takes true or false, or 1 and 0, and STRING takes the text itself, quotation marks optional.

bool   run  = await plc.ReadAsync<bool>("Running");
string name = await plc.ReadAsync<string>("BatchName");

await plc.WriteAsync("Running", true);
await plc.WriteTextAsync("Running", "true");
await plc.WriteTextAsync("BatchName", "Batch 42");

The bit string family, BYTE, WORD, DWORD, LWORD, are bundles of bits rather than numbers, so their text form is plain hexadecimal, two characters per byte, no 0x in front. A WORD takes four hex characters, a DWORD eight, an LWORD sixteen. You write them most significant byte first, exactly the way the value reads in Sysmac Studio, so a WORD you write as 12AB shows as 12AB in Sysmac, and reading it back gives you 12AB again. The library takes care of the controller being little endian internally, you never have to reverse the bytes yourself. The CIP form of TIME, and unions, are also plain hex, but those keep their raw byte order.

var status = await plc.ReadAsync<object>("StatusWord");

await plc.WriteTextAsync("StatusByte",  "FF");
await plc.WriteTextAsync("StatusWord",  "1A2B");        // shows as 1A2B in Sysmac
await plc.WriteTextAsync("StatusDword", "0012ABCD");
await plc.WriteTextAsync("StatusLword", "0000000000000001");

The date and time family reads into natural .NET values, DATE and DATE_AND_TIME arrive as DateTime, TIME_OF_DAY as nanoseconds into the day, and the Omron TIME duration as signed nanoseconds. Writing them as text is just as natural, dates take a readable date string, interpreted as universal time, and the nanosecond types take the plain number.

var when = (DateTime) (await plc.ReadAsync<object>("ProductionDate"))!;
var stamp = (DateTime) (await plc.ReadAsync<object>("LastEvent"))!;

await plc.WriteTextAsync("ProductionDate", "2026-07-18");
await plc.WriteTextAsync("LastEvent", "2026-07-18 10:30:00");
await plc.WriteTextAsync("CycleTime", "1500000000");

12. Writing repeatedly

Some systems need your computer to write on a schedule, most commonly a heartbeat counter the PLC watches to know your program is alive. The valueFactory function is called right before each write so the value is always current, and one failed write does not stop the schedule, which is exactly what you want from a heartbeat.

int counter = 0;

await using var heartbeat = await plc.SubscribeWrite(
    "PcHeartbeat",
    interval: TimeSpan.FromMilliseconds(500),
    valueFactory: () => counter++,
    onError: ex => Console.WriteLine($"Heartbeat write failed: {ex.Message}"));

Console.WriteLine($"Sent {heartbeat.Writes}, failed {heartbeat.Failures}");

The full reference is in section A5.

13. Discovering what variables exist

You do not have to know the names in advance. The controller will list every published variable with its type in one call.

var variables = await plc.ListVariablesAsync();

foreach (var v in variables)
    Console.WriteLine($"{v.Name}  :  {v.TypeName}");

This walks every published tag, so it can take a few seconds on a controller with hundreds of variables. Run it once at startup or on demand, not in a loop. The shape of the result and what else it carries is in section A9.

14. When things go wrong

Failed calls throw exceptions, the .NET way of saying this call failed and here is why. Wrap calls in try and catch when you want to handle failure gracefully.

try
{
    var v = await plc.ReadAsync<float>("MachineSpeed");
}
catch (Exception ex)
{
    Console.WriteLine($"Read failed: {ex.Message}");
}

A useful property of the design is that validation happens before the network. If your value is the wrong shape, you get an error immediately and nothing reaches the controller. The most common failure in practice is a CipException naming a variable, which almost always means the variable is not Network Published or the spelling differs from Sysmac Studio. The complete table of exception types, what each means and what to do, is in section A11.

15. Best practices summary

Connect once and keep the connection, the library heals it for you. Use await using so everything disposes cleanly. Match the .NET type to the PLC declaration. Prefer REAL over LREAL when seven digits of precision is enough, it halves the network work. Read and write individual members of big structures instead of the whole thing when the member is all you need. For anything cyclic, use SubscribeRead with a maxAge instead of writing your own timer loop, and trust the statistics to tell you honestly how you are doing. Leave Sockets and PipelineDepth at one unless you have measured your own controller and found otherwise. Use verified writes for setpoints and commands where you must know the value stuck. And when several variables need protection from each other, give them separate connections, remembering that isolation protects schedules but does not add total speed.

A1. UCMM and Class 3, how the two transports work

The EtherNet IP protocol offers two ways to carry a request to the controller, and the library supports both.

Unconnected messaging, called UCMM, sends each request as a standalone message. The controller receives it, acts on it, and answers. There is no setup and no ongoing state between requests beyond the underlying network session. This is the library default.

Connected messaging, called Class 3, first negotiates a dedicated CIP connection using a request called Forward Open. Once the connection exists, requests ride over it with sequence numbers, and the controller reserves resources for that connection. When you enable it, the library runs a negotiation ladder, trying the small Forward Open variant first and the large variant second. The order matters because Omron controllers reject the large variant outright with a specific status code, 0x0801, so trying small first saves a wasted round trip. If the controller rejects every variant, the library quietly falls back to UCMM and keeps working, so turning the option on can never strand you.

Why is UCMM the default when connected sounds better. Because it was measured. On a direct link Omron serves UCMM faster, and Class 3 adds sequencing overhead, is inherently one request at a time per connection, and carries a negotiated size ceiling that large transfers must fit within. Class 3 earns its keep in a different situation, when infrastructure between you and the controller, certain gateways or plant network standards, requires or prioritizes connected traffic.

await using var plc = await Omron.ConnectAsync("192.168.251.1", new OmronOptions
{
    UseConnectedMessaging = true
});

Everything else in your code stays identical, the switch only changes how requests travel. There is a lower level control at the parity tier for making a rejection throw instead of falling back, documented in the ARCHITECTURE file that ships with the source.

A2. Every connection option

These are the properties of OmronOptions, passed to ConnectAsync. Every default is deliberate.

Property Default What it does and why the default
Port 44818 The TCP port of EtherNet IP. Change only if something on your network remaps it.
RequestTimeout 10 seconds How long to wait for an answer before declaring the request failed with a TimeoutException. Lower it to fail faster on a flaky network.
Sockets 1 Network connections used by the subscription channel. One is correct because the controller takes turns between connections, so more sockets add no speed. See section 3.
PipelineDepth 1 Requests allowed in flight at once per connection. One is correct on NX hardware, deeper pipelines measured slower. See section A10.
SubscriptionMode Strict The scheduling style for subscription reads. Strict runs one read per tick and skips a tick rather than piling up. The alternative Coalescing runs the cycle inline on a dedicated thread. Both prevent backlogs by design.
KeepAliveInterval 30 seconds Idle time before a keep alive is sent to stop the controller closing an idle connection at its ~120 second limit. Uses the normal send path, so it never disturbs reads or writes; a busy connection is skipped. TimeSpan.Zero disables it.
HighPrecisionTiming true Raises the Windows system timer to 1 millisecond while subscriptions run. Without it, Windows timers tick every 15.6 milliseconds and a 50 millisecond schedule actually fires at 47 then 62 alternating. Costs a little power efficiency, buys accurate schedules.
UseConnectedMessaging false False is UCMM, true is Class 3 connected messaging with automatic fallback to UCMM if the controller rejects the connection. See section A1.
Logger silent An optional hook that receives the library internal log messages, for diagnostics. By default nothing is logged.

Worked examples, when and why you would change each option

A shorter timeout suits a supervisory display that should show a red banner quickly rather than hang for ten seconds when the network drops. The reconnect logic keeps retrying in the background either way, the timeout only decides how fast an individual call gives up.

var plc = await Omron.ConnectAsync(host, new OmronOptions
{
    RequestTimeout = TimeSpan.FromSeconds(2)
});

PipelineDepth is worth raising only when the round trip itself is long, for example reaching a controller through routed plant networks or a VPN where each answer takes tens of milliseconds in transit. With several requests in flight the waits overlap and a large chunked read completes sooner. On a directly cabled NX measure first, because depth above one was measured slower there. Change it, run your real read, compare the times, and keep whichever wins.

var plc = await Omron.ConnectAsync(host, new OmronOptions
{
    PipelineDepth = 4
});

SubscriptionMode Coalescing suits a subscription whose read time is close to its poll interval, for example a large structure polled near as fast as it can be read. Coalescing runs each cycle inline on a dedicated thread and simply skips a tick if the previous cycle is still running, which keeps the rhythm steady right at the edge of what the tag allows.

using OmronEip.Fast;

var plc = await Omron.ConnectAsync(host, new OmronOptions
{
    SubscriptionMode = PollMode.Coalescing
});

HighPrecisionTiming can be turned off when every subscription runs at a relaxed period, half a second or slower, where the normal Windows timer resolution is accurate enough and there is no reason to hold the system timer raised, for example on a battery powered laptop doing occasional checks.

var plc = await Omron.ConnectAsync(host, new OmronOptions
{
    HighPrecisionTiming = false
});

The Logger hook is how you see what the library is doing internally when diagnosing a problem, reconnect attempts, calibration results, transport fallbacks. Wire it to the console or to your own logging system. Behind the option sits a small interface called IOmronLogger with four methods, Debug, Info, Warn and Error, each taking a message and an optional data object. DelegateLogger, shown below, is the quick adapter that turns plain functions into that interface, and any level you leave out is simply silent. For a real logging system, implement IOmronLogger directly and forward each method to your framework. The default when you set nothing is NullOmronLogger.Instance, which discards everything.

var plc = await Omron.ConnectAsync(host, new OmronOptions
{
    Logger = new DelegateLogger(
        info: (msg, data) => Console.WriteLine($"info {msg} {data}"),
        warn: (msg, data) => Console.WriteLine($"warn {msg} {data}"))
});

One advanced parameter lives on the subscription call rather than the options. The oversample factor sets how much faster than your age limit the library polls. Raise it for a small tag with a tight limit to buy extra margin against controller variance, since a tiny read costs little. Lower it toward one for a large tag where each poll is expensive and you can tolerate occasionally brushing the limit, trading margin for network load.

var tight = await plc.SubscribeRead<float>("Speed",
    TimeSpan.FromMilliseconds(50), oversample: 4.0);

var heavy = await plc.SubscribeRead<double[]>("Test3",
    TimeSpan.FromMilliseconds(1000), oversample: 1.5);

Sockets is the one setting you should almost never raise. Extra sockets on one connection object add no speed because the controller takes turns between them. When you want isolation between workloads, the pattern is different, open a second Omron object, one per workload that needs protecting.

A3. Every method on the Omron class

One convention covers the whole table: every async method accepts an optional CancellationToken as its final parameter. Pass one to put your own ceiling on a wait or to abandon work when the user navigates away, cancelling completes the call with an OperationCanceledException. The request itself may already be on the wire when you cancel, cancellation releases your program from waiting, it does not unsend a message.

Member What it does
Omron.ConnectAsync(host, options, cancellationToken) Static entry point. Opens the connection pair, resolves nothing yet, returns the ready facade. Options and token are optional.
ReadAsync<T>(tag) Read once, async. T is the expected .NET type per the table in section 4, or object for the natural shape, or your own class for structures.
Read<T>(tag) The blocking twin of ReadAsync.
WriteAsync<T>(tag, value) Write once, async.
Write<T>(tag, value) The blocking twin of WriteAsync.
WriteVerifiedAsync<T>(tag, value) Write, read back, confirm the stored value matches, retrying if not.
WriteTextAsync(tag, text) Write from text in any format, parsed against the tag discovered type. Returns a summary string. See section A8.
WriteText(tag, text) The blocking twin of WriteTextAsync.
WriteTextVerifiedAsync(tag, text) Text write plus read back verification.
ListVariablesAsync(includeSystem) Ask the controller for every published variable name and type. See section A9.
FriendlyTypeName(descriptor) Static helper that renders a type descriptor as a readable name such as REAL or ARRAY[300] OF INT.
SubscribeRead<T>(tag, maxAge, onValue, oversample) Start a repeating read with a guaranteed maximum data age. onValue is optional, oversample defaults to 2.0. Returns a ReadSubscription, see section A4.
SubscribeWrite<T>(tag, interval, valueFactory, onError, verified) Start a repeating write. Returns a WriteSubscription, see section A5.
Disconnected event Raised when the controller is lost, with the triggering exception.
Reconnected event Raised when the automatic reconnect restores the controller.
Advanced property Exposes the underlying multi lane client for specialist scenarios, documented in the ARCHITECTURE file that ships with the source.
DisposeAsync Closes everything deterministically. Called automatically by await using.

A4. The read subscription, every property and method

SubscribeRead returns a ReadSubscription of T. Everything on it is safe to touch from any thread.

Member What it means
Tag The variable name this subscription reads.
MaxAge The maximum data age you asked for, the oldest a value is ever allowed to be.
PollInterval The interval it actually polls at. Normally maxAge divided by the oversample factor, but floored by a calibration of how fast your tag can physically be read, so a huge tag with a tiny maxAge shows a larger interval here, honestly.
Updates How many new values have arrived since the start.
Failures How many reads failed. Failures do not stop the schedule, they raise the Error event and count here.
ValueReceived event The data arrived event. Fires at the moment each read completes with new data, on a background thread, one invocation at a time in arrival order. The onValue parameter of SubscribeRead is a convenience that attaches to this same event, and you may attach several listeners and add or remove them while running.
Error event Fires with the exception of each failed read.
TryGetLatest(out value, out age) Pull style access. Hands you the newest copy already held plus how old it is, never blocks, never touches the network. Returns false only before the first value arrives.
Latest The newest value, or the type default before the first arrival, when you do not need the age.
FractionWithinMs(ms) Of all arrival gaps since the start, the fraction at or under the given milliseconds. Call it with your maxAge in milliseconds and it answers am I hitting my rate over the whole run.
RecentFractionWithinMs(ms) The same measure over only the last few hundred arrivals, the current behavior. The lifetime number blends every good hour before a problem started and slides slowly, this one reacts within seconds. Lifetime high while recent is low means something changed partway through the run.
GapPercentileMs(p) The arrival gap percentile, p between 0 and 1. GapPercentileMs(0.99) is the gap that 99 percent of arrivals beat.
GapStats() Average, minimum, maximum and count of the arrival gaps in one call.
DisposeAsync Stops the schedule, releases the precision timer, detaches every listener. Idempotent.

Each delivered value is a freshly decoded instance the library never touches again, so holding it, queueing it, or reading it from several threads is safe as long as no one mutates it, copy first when you need to modify. The statistics are measured from when each read actually completed, not from when your callback happened to run, so thread scheduling cannot distort them, and they use a fixed bounded amount of memory, safe for runs that last days.

A5. The write subscription reference

Member What it means
Tag The variable being written.
Interval The write period you asked for.
Writes Successful writes so far.
Failures Failed writes so far. A failure raises Error and the schedule continues.
Error event Fires with the exception of each failed write.
DisposeAsync Stops the schedule cleanly.

At subscribe time you can also pass verified as true, making every scheduled write a verified write, at the cost of one extra read each cycle.

A6. Member paths the controller refuses, and the automatic fallback

Real NX hardware accepts direct symbolic addressing of single members inside structures, even nested ones, but refuses array members inside structures, for reads and writes alike, answering with status 0x04 which means path segment error. This was verified on an NX502 with a BOOL array member at two nesting levels. It is a rule of the controller firmware, not of this library, and it exists because such arrays are bit packed into words inside the structure and the controller does not expose them as addressable sub symbols.

The library handles it for you in both directions. On a read, it detects the refusal, reads the whole root structure instead, and extracts your member from the decoded result. On a write, it reads the parent structure, sets your member in the copy, and writes the structure back whole, so every other member keeps its value, and the returned summary tells you the fallback was used. And it remembers. The first access of such a path pays one rejected request to learn, and every later access on that connection skips straight to the fallback with no wasted round trip.

Warning: Know the cost. The fallback works through the whole root structure, so each access costs a full structure transfer. For a structure containing a ten thousand element array that is hundreds of messages. For one shot reads and configuration writes that is fine. For cyclic access, subscribe to the root structure with your data age limit and pick the member out of the dictionary in your callback, one structure read then serves every member at once. If you truly need one flag inside a structure at high speed, the clean solution is on the PLC side, mirror it into a published top level variable, which reads in a single message.

Paths with an element index such as a bracketed number are deliberately excluded from the fallback, because arrays in Sysmac may declare a non zero lower bound and remapping indices through a merge could silently touch the wrong element. Those paths surface the controller error instead of guessing.

A7. Structures as your own C# classes

Instead of dictionaries you can define a class whose properties match the structure members by name, and read straight into it. Nested structures become nested classes, and elementary arrays become typed arrays.

public class MyDataType2
{
    public bool Bool1 { get; set; }
    public double[] Array_LREAL { get; set; } = [];
}

public class MyDataType1
{
    public float Test_Real { get; set; }
    public MyDataType2 Test_Custom { get; set; } = new();
}

MyDataType1 data = await plc.ReadAsync<MyDataType1>("StructTest1");
Console.WriteLine(data.Test_Custom.Bool1);

Two attributes tune the mapping. Put CipMember with the PLC name on a property whose C# name differs, and CipIgnore on a property the library should skip entirely.

Warning: One caution for writing. Writing a whole structure from a class writes every member, and any member missing from your class is written as zero, because that is the protocol whole structure rule. The keep missing members behavior belongs to the JSON text write described in section 10. When in doubt, write the individual member.

A8. How text writing parses your input

The text write methods resolve the tag type from the controller and then parse your string against it. Scalars accept plain forms, numbers, true and false, and bare text for strings. Arrays accept comma separated values or a JSON array, elements parsed per the element type. Structures require a JSON object, and it may be partial, with unmentioned members keeping their current values through the read merge write described in section 10. Structure member names are validated against the real members and a wrong name produces an error listing the valid ones.

The JSON is normalized before parsing so hand typed input just works. Bare member names get quotes, so an input like curly brace Bool1 colon False closes fine. Capitalized True, False and None become their JSON forms. A missing comma at a line break between members is inserted. Text inside quotation marks is never altered by any of this. Raw byte types are written as hexadecimal strings, plain hex with two characters per byte and no 0x in front, and when you copy a read result those members appear the same way so the round trip is lossless. The bit string integers BYTE, WORD, DWORD and LWORD are written and read most significant byte first, matching how Sysmac Studio displays them, the library handles the little endian wire order for you. The CIP form of TIME and unions keep their raw byte order instead, since a union has no single numeric value to order. The date family is friendlier than that, DATE and DATE_AND_TIME accept a readable date string such as 2026-07-18 10:30:00, interpreted as universal time, while the Omron duration and time of day types take their nanosecond count as a plain number.

The parsing machinery is also public, in a static class called TagValueText, in case your own program wants it before a write ever happens. NormalizeJson applies the forgiving cleanups described above and returns proper JSON, useful for validating or previewing what a person typed into your interface, and ParseScalar and ParseArray convert text against a type descriptor the same way the write path does.

A9. The discovery result in detail

ListVariablesAsync returns a sorted list of PlcVariable records. Each carries Name, the exact spelling to use in every other call. TypeName, a readable rendering such as REAL, STRING[256], ARRAY[1000] OF REAL, or the structure own type name. IsSystem, true for the controller internal variables whose names start with an underscore, which are only included when you pass includeSystem as true. And Descriptor, the full machine readable type information, useful when your program needs to branch on the shape of a variable, for example mapping discovered variables onto typed subscriptions automatically.

Under the hood this is the same mechanism the original Node library called update variable dictionary. The library pages through the controller tag name server for the list of published names, then resolves each name type. Names whose type cannot be resolved are skipped rather than failing the whole walk.

A10. Performance, the 500 byte rule and what was measured

Every message in this protocol is capped at about 500 bytes. A single number fits easily, but a large array does not, so the library transparently splits big transfers into chunks of about 494 bytes and reassembles them. Because each chunk costs one round trip of roughly 2 milliseconds on an NX502 and up to about 4.5 on smaller controllers, the number of chunks is what determines speed. A thousand REAL values is four thousand bytes which is nine chunks. The same data as LREAL is eight thousand bytes and seventeen chunks, twice the time. That is why the single cheapest optimization is using REAL instead of LREAL wherever seven digits of precision suffices.

Three findings from real hardware shape the defaults and the advice. First, the controller takes turns between connections, so extra sockets never add speed, only isolation, as section 3 explained. Second, the NX502 is penalized by pipeline depth, one request in flight measured fastest and depth four or more roughly tripled latencies, so leave PipelineDepth at one. Third, the remaining variance in answer times belongs to the controller servicing communications between its own task cycles, which no client setting removes, and which the maxAge oversampling of subscriptions is designed to ride over.

Warning: A large array read is not a snapshot. A transfer of many chunks spans multiple PLC scans, so the first and last chunk can come from different moments, and if the PLC program was mid update the copy can mix old and new values. Where that matters, use a handshake in the PLC program, the PLC fills the array then sets a BOOL, your program sees the BOOL, reads the array, clears the BOOL.

A11. Error reference

Exception Meaning Typical cause and fix
CipException The controller itself refused and sent a status code Variable misspelled or not Network Published, or an operation the controller does not allow for that path. The message includes the Omron status. Array members inside structures are handled automatically by the fallback, see A6.
TimeoutException No answer within RequestTimeout Wrong address, controller off, cable, or a firewall blocking TCP port 44818.
InvalidOperationException Not connected right now The automatic reconnect is working on it. Retry shortly.
ArgumentException and FormatException Your input was rejected before anything was sent Wrong .NET type for the tag, text that does not parse, or an unknown structure member, in which case the message lists the real members. The controller was never touched.
NotSupportedException The operation genuinely is not available For example an unsupported element type. The message explains.

When your program needs to react to a specific controller status rather than just show the message, CipException carries the status as typed properties. GeneralStatus is the numeric status byte, the value this guide has been quoting as 0x04 and friends, ExtendedStatus is the accompanying byte array for statuses that carry one, and StatusCode and ExtendedStatusCode are the same values ready formatted as hexadecimal text for logs. Branching on GeneralStatus is the reliable way to distinguish, say, a variable that does not exist from an operation the controller refuses.

try
{
    await plc.WriteAsync("Setpoint", 12.5f);
}
catch (CipException ex) when (ex.GeneralStatus == 0x04)
{
    Console.WriteLine("The controller does not know that path, check the name and publishing.");
}
catch (CipException ex)
{
    Console.WriteLine($"Controller refused, status {ex.StatusCode}");
}

The CipException type lives in the OmronEip.Cip namespace, so add a using for it when you catch it by name.

Subscriptions are the one place failures do not throw, because there is no call of yours to throw into. They raise the Error event, increment Failures, and keep the schedule running.

A12. Disposal, resource lifetime, and how memory leaks were ruled out

Everything you open in this library holds real resources, and this section spells out exactly what those are, what disposal releases, and the evidence that nothing lingers.

What is actually held

A connection, one Omron object, owns a TCP socket to the controller and a background receive loop that reads answers off the wire. A running subscription owns more, a repeating schedule, a claim on the high precision system timer while it runs, the stored latest value, and references to every callback and event listener you attached. None of these are cleaned up by garbage collection alone on any useful schedule, which is why disposal is explicit.

What disposal does

Disposing a subscription is complete and immediate. It detaches its internal handlers from the polling machinery, sets its ValueReceived and Error events to null, which releases the references to your listener objects so they become collectable, stops the polling loop, and releases the precision timer claim. Disposing a connection closes the socket and shuts down the receive loop. Both are idempotent, meaning disposing twice is harmless, the second call simply returns, so defensive code paths that might dispose the same object again cost nothing.

Order matters in one simple way, subscriptions before their connection, because a subscription still running against a closed socket can only fail. The await using pattern gives you this for free, declarations dispose in reverse order, so declaring the connection first and the subscription second means the subscription is disposed first when the scope ends.

await using var plc = await Omron.ConnectAsync("192.168.250.1");
await using var sub = await plc.SubscribeRead<float[]>("TestR",
    TimeSpan.FromMilliseconds(50));

// work with the subscription, then leave the scope,
// the subscription is disposed first, then the connection,
// including when an exception is thrown anywhere inside

When the lifetime does not fit one scope, a window that starts on one button and stops on another, keep the objects in fields and dispose them explicitly in your stop path, and put that stop path in a finally or a window closing handler so it runs on every exit route.

try
{
    // running
}
finally
{
    if (sub is not null) await sub.DisposeAsync();
    if (plc is not null) await plc.DisposeAsync();
}

How memory leaks were ruled out by design

Three design rules keep memory flat no matter how long a subscription runs. The value store holds exactly one latest copy, each update replaces the reference, there is no history accumulating. The statistics live in fixed size structures, a histogram of two thousand and one buckets for the lifetime numbers and a ring of two hundred fifty six slots for the recent window, so a subscription that has delivered ten million values occupies the same memory as one that has delivered ten. And each delivered value is a fresh allocation the library forgets after handing it over, so ordinary garbage collection reclaims values your program has finished with.

How it was verified, not just asserted

The automated suite contains a test that takes a true garbage collected memory baseline, then starts and fully stops a subscription one hundred times in a row, then collects again and asserts the managed heap has not grown at all, zero, not a tolerance. The same test asserts the process has not accumulated threads, because a subscription leaking one thread per cycle would show up as roughly a hundred extra. One honest observation belongs next to that: after a stop you may still see an elevated thread count for a while in a debugger, which is the .NET runtime keeping warm worker threads parked for reuse, normal platform behavior and not a leak, the heap measurement is the truthful indicator, and the test measures exactly that.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.4 47 9/29/2026

Fully Tested with Omron NX/NJ controllers