Caution
DO NOT USE THIS LIBRARY IN PRODUCTION. This library is currently untested against ControlLogix hardware. It is under active development and has only been validated against the synthetic EtherNet/IP server found in /tools.
A lightweight, non-blocking EtherNet/IP and CIP (Common Industrial Protocol) client for the ESP32, purpose-built to talk to Allen-Bradley/Rockwell ControlLogix and compatible Logix controllers. No external dependencies, bounded memory, and a simple API.
- Features
- Supported Data Types
- Requirements
- Installation
- Quick Start
- API Reference
- Configuration
- Examples
- Architecture
- Limitations
- Best Practices
- Troubleshooting
- Version History
- Simple & Beginner-Friendly : Simple API with a single top-level
PlcClient. - Fully Non-Blocking : No public call waits on network, socket, or PLC progress.
begin()/connect()/read()/write()start work and return immediately;poll()advances everything. - Bounded Memory : Fixed-size tag pool and reusable packet buffers.
- Native Protocol Stack : EtherNet/IP encapsulation, CIP explicit messaging, connected (Forward Open / SendUnitData) and unconnected (SendRRData) messaging.
- Symbolic Logix Tags : Read and write symbolic tags with typed accessors for scalars, arrays, and strings.
- Callbacks : Optional tag-completion and connection-state callbacks for event-driven integration.
- Reconnect & Recovery : Handles timeouts, disconnects, stale responses, and PLC restarts.
- Transport-Independent : Session and CIP logic are decoupled from the underlying transport (Wi-Fi or Ethernet).
- Testable : Includes a host-side Python-based synthetic EtherNet/IP server for validation without hardware.
Typed accessors are provided for the following Logix elementary data types:
| Data Type | CIP Code | Size | Accessors |
|---|---|---|---|
| BOOL | 0xC1 |
1 byte | getBool / setBool |
| SINT | 0xC2 |
1 byte | getInt8 / setInt8 |
| INT | 0xC3 |
2 bytes | getInt16 / setInt16 |
| DINT | 0xC4 |
4 bytes | getInt32 / setInt32 |
| LINT | 0xC5 |
8 bytes | getInt64 / setInt64 |
| USINT | 0xC6 |
1 byte | getUint8 / setUint8 |
| UINT | 0xC7 |
2 bytes | getUint16 / setUint16 |
| UDINT | 0xC8 |
4 bytes | getUint32 / setUint32 |
| ULINT | 0xC9 |
8 bytes | getUint64 / setUint64 |
| REAL | 0xCA |
4 bytes | getFloat32 / setFloat32 |
| LREAL | 0xCB |
8 bytes | getFloat64 / setFloat64 |
| STRING | 0xD0 |
variable (4-byte length + data) | getString / setString |
- Hardware : Any ESP32-family board. The default configuration targets the M5Stack AtomS3 with the AtomPoE (W5500) shield, but other SPI or RMII Ethernet PHYs (e.g. LAN8720) are supported.
- Board Library : Arduino-ESP32 3.3.11
- Network : Static IPv4 configuration is provided by the sketch. A ControlLogix PLC (or the synthetic server) listening on port 44818.
- Copy the
ESP32ControlLogixfolder into your Arduinolibrariesdirectory. - Restart the Arduino IDE.
- Include the umbrella header in your sketch:
#include <ESP32ControlLogix.h>The PlcClient class is the easiest way to get started. It owns the full stack - Ethernet, TCP, EtherNet/IP session, and a tag pool for memory efficiency.
#include <ESP32ControlLogix.h>
clx::PlcClient plc;
int tag = -1;
bool started = false;
void setup() {
Serial.begin(115200);
// Static IPv4 for the AtomPoE on the target LAN.
clx::Client::Config cfg;
cfg.ip = IPAddress(192, 168, 1, 50);
cfg.gateway = IPAddress(192, 168, 1, 1);
cfg.subnet = IPAddress(255, 255, 255, 0);
cfg.dns = IPAddress(192, 168, 1, 1);
plc.begin(cfg); // start Ethernet
plc.connect(IPAddress(192, 168, 1, 2), 44818, 5000); // queue TCP + session
}
void loop() {
plc.poll(); // advance everything
if (plc.ready() && !started) {
tag = plc.createTag("MyTag"); // allocate a tag (1 element)
if (tag >= 0) { // check for a valid handle
started = true;
plc.read(tag, 5000); // start a read
}
// else: pool exhausted (NoMemory) or bad name (InvalidArg) - retry later
}
if (started && plc.tagStatus(tag) == clx::Status::Ok) {
int32_t v = plc.tag(tag)->getInt32(0); // read the value
Serial.printf("MyTag = %ld\n", (long)v);
plc.destroyTag(tag);
started = false;
}
delay(10); // Do other work...
}All symbols live in the clx namespace. Every call is non-blocking: methods that begin an operation return immediately, and poll() advances state.
The high-level facade that owns the Ethernet client, TCP connection, EtherNet/IP session, and a bounded tag pool.
class PlcClient {
static constexpr size_t kMaxTags = 8; // max simultaneous tags
static constexpr size_t kMaxTagName = 64; // max tag name length
Status begin(const Client::Config &cfg); // start Ethernet
Status connect(const IPAddress &ip, uint16_t port, uint32_t timeoutMs);
Status poll(); // advance everything
Status disconnect(); // graceful disconnect
int createTag(const char *name, uint32_t elementCount = 1); // >=0 handle, or negative Status
Status destroyTag(int handle);
Status read(int handle, uint32_t timeoutMs);
Status write(int handle, uint32_t timeoutMs);
Status tagStatus(int handle) const; // non-advancing
Tag *tag(int handle); // typed access (nullptr if invalid)
Status abortTag(int handle);
void setTagCallback(TagCallback cb, void *userData);
void setStateCallback(StateCallback cb, void *userData);
bool ready() const;
uint32_t sessionHandle() const;
int tagCount() const;
};Callbacks:
TagCallback:void (*)(int handle, Status status, void *userData)- invoked once when a tag's read/write completes.StateCallback:void (*)(Status status, void *userData)- invoked on connect (Ok) and disconnect (Closed).
A single Logix tag: symbolic name, typed data buffer, and non-blocking Read Tag (0x4C) / Write Tag (0x4D) over unconnected messaging.
class Tag {
static constexpr size_t kMaxDataSize = 256;
Status read(TcpConnection &conn, uint32_t sessionHandle, const char *name,
uint32_t elementCount, uint32_t timeoutMs);
Status write(TcpConnection &conn, uint32_t sessionHandle, const char *name,
uint32_t elementCount, uint32_t timeoutMs);
Status poll();
void abort();
Status status() const;
uint8_t dataType() const;
const uint8_t *data() const;
size_t dataLength() const;
uint8_t resultCode() const;
void setDataType(uint8_t type);
// Typed accessors (offset is a byte offset into the data buffer).
bool getBool(size_t off) const;
int8_t getInt8(size_t off) const;
int16_t getInt16(size_t off) const;
int32_t getInt32(size_t off) const;
int64_t getInt64(size_t off) const;
uint8_t getUint8(size_t off) const;
uint16_t getUint16(size_t off) const;
uint32_t getUint32(size_t off) const;
uint64_t getUint64(size_t off) const;
float getFloat32(size_t off) const;
double getFloat64(size_t off) const;
// ...and matching setBool/setInt8/.../setFloat64 setters.
size_t getString(char *buf, size_t bufLen) const;
void setString(const char *s);
};The data type is learned from a read (or set explicitly via setDataType()); a write requires a known data type.
A connected CIP connection (Forward Open / SendUnitData / Forward Close). Every received packet is bounds-checked, and responses are rejected unless the connection ID and sequence number match the request - rejecting stale, replayed, and malformed data.
class Connection {
static constexpr size_t kMaxDataSize = 256;
Status open(TcpConnection &conn, uint32_t sessionHandle, const char *tagName,
uint32_t timeoutMs);
Status send(TcpConnection &conn, uint32_t sessionHandle, uint8_t service,
const uint8_t *path, size_t pathLen,
const uint8_t *data, size_t dataLen, uint32_t timeoutMs);
Status close(TcpConnection &conn, uint32_t sessionHandle, uint32_t timeoutMs);
Status poll();
uint32_t originatorConnectionId() const; // O->T
uint32_t targetConnectionId() const; // T->O
bool isOpen() const;
uint8_t replyService() const;
uint8_t resultCode() const;
const uint8_t *data() const;
size_t dataLength() const;
};One CIP explicit-message exchange (SendRRData / unconnected messaging). The data() pointer is valid until the next send().
class ExplicitMessage {
static constexpr size_t kMaxCipData = 256;
Status send(TcpConnection &conn, uint32_t sessionHandle, uint8_t service,
const uint8_t *path, size_t pathLen,
const uint8_t *data, size_t dataLen, uint32_t timeoutMs);
Status poll();
void abort();
uint8_t replyService() const;
uint8_t resultCode() const;
const uint8_t *data() const;
size_t dataLength() const;
};EtherNet/IP encapsulation session (RegisterSession / UnregisterSession) with bounded receive framing.
class Session {
static constexpr size_t kMaxBodySize = 512;
Status open(TcpConnection &conn, uint32_t timeoutMs);
Status poll();
Status close();
void abort();
uint32_t handle() const;
bool registered() const;
};Non-blocking Ethernet client over the ESP32 ETH driver with a static IPv4 configuration.
class Client {
struct Config {
eth_phy_type_t phyType = ETH_PHY_W5500;
IPAddress ip{192, 168, 1, 50};
IPAddress gateway{192, 168, 1, 1};
IPAddress subnet{255, 255, 255, 0};
IPAddress dns{192, 168, 1, 1};
int sck = 5, miso = 7, mosi = 8, cs = 6; // SPI pins
int irq = -1, rst = -1, phyAddr = 1;
bool rmii = false; // true = RMII PHY
int mdc = 23, mdio = 18, power = -1, clkMode = 0;
};
Status begin(const Config &cfg);
Status poll();
bool ready() const;
IPAddress localIP() const;
IPAddress subnetMask() const;
};Non-blocking TCP connection over lwIP sockets.
class TcpConnection {
Status connect(const IPAddress &ip, uint16_t port, uint32_t timeoutMs);
Status poll();
int read(uint8_t *buffer, size_t length); // >0 bytes, 0 would-block, <0 error
int write(const uint8_t *buffer, size_t length);
void close();
bool connected() const;
int fd() const;
};Every operation returns a clx::Status. Pending (0) and Ok (1) are the progress/success path; every failure value is negative.
| Value | Name | Meaning |
|---|---|---|
0 |
Pending |
Operation in progress; call poll() again |
1 |
Ok |
Operation completed successfully |
2 |
Busy |
Resource already active |
3 |
WouldBlock |
Non-blocking I/O would block; retry later |
-1 |
Closed |
Connection closed / not connected |
-2 |
Timeout |
Deadline exceeded |
-3 |
NotReady |
Transport not ready (no link / no IP) |
-4 |
NoMemory |
Allocation failure |
-5 |
InvalidArg |
Invalid argument |
-6 |
Error |
Generic / unspecified error |
Use clx::statusString(status) to get a human-readable name.
Path-encoding and data-type helpers in clx:
size_t appendClass(uint8_t *out, uint8_t classId); // 8-bit class segment
size_t appendInstance(uint8_t *out, uint8_t instanceId); // 8-bit instance segment
size_t appendAttribute(uint8_t *out, uint8_t attributeId); // 8-bit attribute segment
size_t appendSymbolic(uint8_t *out, const char *name); // symbolic tag segment
size_t dataTypeElementSize(DataType t);
const char *dataTypeName(DataType t);Little-endian encode/decode helpers (putU16, putU32, putU64, getU16, getU32, getU64) and the EncapsulationHeader struct with encodeHeader()/decodeHeader() are also available for advanced use.
The sketch supplies the Ethernet configuration via clx::Client::Config. The defaults match the M5Stack AtomS3 + AtomPoE (W5500) SPI PHY:
clx::Client::Config cfg;
cfg.ip = IPAddress(192, 168, 1, 50);
cfg.gateway = IPAddress(192, 168, 1, 1);
cfg.subnet = IPAddress(255, 255, 255, 0);
cfg.dns = IPAddress(192, 168, 1, 1);To use an RMII PHY (e.g. LAN8720) on a board with the built-in EMAC:
cfg.phyType = ETH_PHY_LAN8720;
cfg.rmii = true;
cfg.phyAddr = 0;
cfg.mdc = 23;
cfg.mdio = 18;
cfg.power = -1;
cfg.clkMode = ETH_CLOCK_GPIO0_IN; // 0The tag pool size (PlcClient::kMaxTags) defaults to 8. To change it, define ESP32_CONTROLLOGIX_MAX_TAGS before including the header:
#define ESP32_CONTROLLOGIX_MAX_TAGS 16
#include <ESP32ControlLogix.h>The examples/ directory contains progressively lower-level demos:
| Example | Description |
|---|---|
PlcClientDemo |
Top-level PlcClient API: connect, read-modify-write of a DINT tag. |
TagReadWrite |
Unconnected Tag read/write for DINT, REAL, and STRING tags. |
ConnectedTagReadWrite |
Connected messaging (Connection): Forward Open -> Read -> Write -> Verify -> Forward Close. |
IdentityQuery |
CIP Identity Object Get_Attribute_Single queries via ExplicitMessage. |
EipSession |
EtherNet/IP Session register/unregister. |
LanInventory |
LAN inventory scan. |
ReliabilityDemo |
Reconnect, abort, and resource-stress behavior. |
Each example can point at a real ControlLogix PLC or at the host-side synthetic server (tools/synthetic_eip_server.py).
Application / public tag API (PlcClient, Tag)
|
Tag registry and lifecycle manager
|
Request scheduler and bounded transaction pool
|
EtherNet/IP session manager (Session)
|
CIP encoder/decoder and Logix symbolic path builder (ExplicitMessage, Connection, Cip)
|
Transport interface (Client, TcpConnection)
| |
ESP32 Wi-Fi ESP32 W5500 Ethernet
Key design goals:
- Non-blocking : No public call waits for network, socket, or PLC progress. Timeouts are deadlines checked from
poll(), never sleeps or blockingselect(). - Bounded RAM : Fixed tag pool (
kMaxTags = 8), fixed buffers, no heap allocation after construction. - Minimal task count : One shared worker task / event-driven scheduler, not one task per session or tag.
- Reusable buffers : Packet buffers are reused.
- Predictable failure : Allocation failure returns a defined error.
- Bounded tag pool :
kMaxTags(default 8) simultaneous tags;createTag()returnsNoMemorywhen exhausted. Override viaESP32_CONTROLLOGIX_MAX_TAGS. - Bounded buffer : Tag data is capped at 256 bytes
- Static IP : The current transport targets static IPv4 configuration; DHCP is not yet wired into the public API.
- Poll in a loop : Drive everything with
poll(); never assume a call completes synchronously. - Check return values : Always verify
Statuscodes -Pendingmeans "call again", negative values are failures. - Respect the tag pool :
destroyTag()handles you no longer need to avoid exhaustingkMaxTags. - Verify writes : Read back after a write to confirm the PLC accepted the value.
- Use callbacks : Prefer
setTagCallback/setStateCallbackfor event-driven designs instead of tight polling. - Set timeouts : Every operation takes a
timeoutMsdeadline; choose values appropriate to your network.
begin() returns Error
- Check the Ethernet PHY configuration matches your hardware (SPI vs. RMII pins).
connect() never becomes ready()
- Verify the target IP/port and that the PLC (or synthetic server) is reachable on port 44818.
- Confirm the static IP/subnet/gateway are correct for your LAN.
createTag() returns a negative value
- The tag pool is exhausted (
NoMemory) - free unused tags withdestroyTag().
tagStatus() returns Timeout
- The read/write deadline elapsed. Increase
timeoutMsor check network/PLC health.
Read value looks wrong
- Confirm the data type matches the tag's Logix type; use the correct typed accessor (e.g.
getInt32for DINT).
- 0.1.0 : Initial release.