| name | goindustrial |
| description | Communicate with industrial PLCs using Modbus TCP and EtherNet/IP (CIP) via github.com/iceisfun/goindustrial. Covers client/server setup, register and tag operations, reconnection, monitoring, protocol-agnostic PLC access, and optional GoLua scripting bindings. |
| license | MIT |
| compatibility | claude-code, opencode |
| metadata | {"language":"go","domain":"industrial-automation"} |
GoIndustrial Skill
Use this when helping someone who imported github.com/iceisfun/goindustrial and wants to communicate with Modbus TCP or EtherNet/IP devices.
SKILLS
Copy-paste block for an AI assistant:
SKILLS:
- GoIndustrial is a pure-Go library for Modbus TCP and EtherNet/IP (CIP) industrial protocols. Zero external dependencies.
- Two protocol clients, both implementing plc.PLC: modbus.Client and ethernetip.Client.
- Modbus quick path: modbus.Connect(ctx, host, opts...) -> client.ReadHoldingRegisters / WriteSingleRegister / etc.
- EtherNet/IP quick path: ethernetip.Connect(ctx, addr, opts...) -> client.ReadTag / WriteTag / ReadTagInto / etc.
- Both protocols support reconnecting transports: transport.NewReconnectingTransport[C](connector, closer, opts...).
- Functional options everywhere: modbus.WithRetries(3), modbus.WithUnitID(1), ethernetip.WithRetryDelay(2*time.Second), etc.
- Wire-level hex dump tracing: modbus.WithHexDump(os.Stdout) or ethernetip.WithHexDump(w) dumps all TCP traffic in hexdump -C format. Uses hexdump.Dumper which wraps io.Reader/io.Writer. Use io.MultiWriter to write to both stdout and a file.
- Monitor polls any plc.Reader: monitor.NewMonitor(reader) -> m.Subscribe(dataPoint, monitor.WithFrequency(100*ms)).
- Adaptive read clustering: monitor.NewClusteringReader(modbusClient, monitor.WithGapThreshold(32)) wraps a reader to coalesce nearby Modbus addresses into block reads. Reduces N subscriptions from N requests to ~1 per cluster. Supports cache TTL for cross-goroutine sharing, singleflight dedup, and WithClusteringEnabled(false) to force OFF.
- Clusterable interface: Modbus data point types (HoldingRegister, InputRegister, Coil, DiscreteInput) implement ClusterKey/ClusterAddr/ClusterQty/ClusterMerge/ClusterExtract for protocol-agnostic clustering.
- Modbus data areas: Coils (bool R/W), Discrete Inputs (bool R), Holding Registers (uint16 R/W), Input Registers (uint16 R).
- CIP data types: BOOL(0xC1), SINT(int8), INT(int16), DINT(int32), LINT(int64), USINT(uint8), UINT(uint16), UDINT(uint32), ULINT(uint64), REAL(float32), LREAL(float64), STRING(0xD0).
- Custom CIP types: implement cip.TypeCodec (MarshalCIP + UnmarshalCIP + CIPType()) and call cip.RegisterType(code, factory) at init() time. Optional fmt.Stringer for display name. cip.LookupType(code) returns codec; DataType.String() resolves registered names.
- Vendor packages: vendor/rockwell provides TypeCodec wrappers for Timer, Counter, PID, Control. Call rockwell.RegisterTimer(dt), etc. with controller-specific type codes from ListTags.
- Error classification: Modbus protocol errors (IsModbusError) are not retried; transport errors trigger Reset + retry. Same pattern for EIP: cipError not retried, transport errors retried.
- Servers: modbus.NewServer(addr, opts...) and ethernetip.NewServer(router, opts...). Both support net.Pipe injection for testing.
- EIP server has session management (unique handles, validation), ListIdentity/ListServices, client tracking (ConnectedClients()), connect/disconnect callbacks.
- Implicit I/O (cyclic messaging): IOScanner (client) sends Forward_Open, exchanges assembly data over UDP at RPI. Adapter (server) wires ConnMgr callbacks to Runtime for automatic I/O connection setup.
- IOScanner: NewIOScanner(session, udpAddr) -> scanner.Open(ctx, cfg) -> conn.SetOutput(data) / conn.Input() / conn.IsTimedOut() -> scanner.Close(ctx, conn).
- Adapter pattern: AssemblyObject + Runtime (UDP) + Scheduler (5ms tick) + ConnectionManager with WithOnOpen/WithOnClose callbacks wired to Runtime.AddConnection/RemoveConnection.
- Monitor subscribers: mon.NewSubscriber(bufferSize) returns Subscriber with buffered channel. sub.All() is iter.Seq[Event] for for-range. sub.Done() to unregister. Never blocks the monitor.
- plc.Value carries Type (DataType enum) and ByteOrder. Helper methods: val.Bool(), val.Int(), val.Uint(), val.Float32(), val.Float64() decode Raw using the value's byte order.
- Device probe: examples/ethernetip/probe scans identity, TCP/IP interface, Ethernet link, assembly instances, CIP object classes, connection manager stats, and optionally Logix tags.
- Optional Lua bindings (lua/ package, requires github.com/iceisfun/golua/v2 — Lua 5.5.0): industrialLua.Open(v) registers "modbus" and "eip" Lua globals.
- Lua modbus API: modbus.connect(addr, opts) -> client, client:read_holding_registers(addr, qty), client:write_register(addr, val), etc.
- Lua eip API: eip.connect(addr, opts) -> client, client:read_tag(name) -> auto-typed value, client:write_tag(name, val), client:list_tags(), etc.
- Lua methods use colon syntax (client:method(args)); the self parameter is handled automatically.
- PCCC for SLC 500 / MicroLogix (pccc/ sub-package): legacy AB controllers expose no named tags. Address data-table files (N7:0, B3:0/2, F8:5, T4:0.ACC, S:1) and tunnel PCCC inside CIP Execute_PCCC (class 0x67, service 0x4B). PLC-5 word-range commands are out of scope.
- PCCC high-level client: pccc.NewClient(eip) wraps an *ethernetip.Client; implements plc.Reader + plc.Writer; methods ReadAddress / ReadWords / WriteAddress / WriteWords + Read(ctx, points...) / Write(ctx, point, data). Bit writes (e.g. B3:0/2) are read-modify-write under the hood.
- PCCC data point: pccc.File{Address: "N7:0", Count: 1} works directly with monitor.NewMonitor — no PCCC-specific monitor code needed.
- PCCC value decoding: integer files -> plc.TypeInt16, float -> plc.TypeFloat32, bit suffix -> plc.TypeBool, whole timer/counter/control elements -> plc.TypeBytes (use pccc.DecodeTimer / pccc.DecodeCounter, both yielding {Control, PRE, ACC} + bit accessors EN/TT/DN/CU/CD/OV/UN).
- PCCC errors: STS / EXT STS surfaces as *pccc.Error (check with pccc.IsPCCCError); CIP-layer errors surface as cip.Error; transport errors retry via the EIP client's retry config.
- PCCC low-level escape hatch: ethernetip.Client.ExecutePCCC(ctx, rawPCCC) sends pre-encoded PCCC bytes and returns raw reply bytes. Use pccc.EncodeTypedRead/EncodeTypedWrite + pccc.DecodeReply for the framing.
- PCCC requestor ID: configurable per-client with ethernetip.WithCIPVendorID(uint16) / ethernetip.WithCIPSerialNumber(uint32); defaults are DefaultCIPVendorID = 0x1234 and DefaultCIPSerialNumber = 0x12345678.
What You Usually Need To Know
Most users want one of these:
- Read/write Modbus registers or coils from a TCP device.
- Read/write tags on a Rockwell Logix PLC over EtherNet/IP.
- Monitor data points from either protocol with change detection.
- Establish implicit I/O (cyclic) connections for real-time assembly exchange.
- Probe an EtherNet/IP device to discover identity, assemblies, and capabilities.
- Build a server/simulator/adapter for testing.
Module and Imports
Module: github.com/iceisfun/goindustrial
import (
"github.com/iceisfun/goindustrial/logging"
"github.com/iceisfun/goindustrial/transport"
"github.com/iceisfun/goindustrial/plc"
"github.com/iceisfun/goindustrial/monitor"
"github.com/iceisfun/goindustrial/protocol/modbus"
"github.com/iceisfun/goindustrial/protocol/ethernetip"
"github.com/iceisfun/goindustrial/protocol/ethernetip/cip"
"github.com/iceisfun/goindustrial/protocol/ethernetip/eip"
)
Modbus TCP Client
Connect and Read
ctx := context.Background()
client, err := modbus.Connect(ctx, "192.168.1.10",
modbus.WithPort(502),
modbus.WithUnitID(1),
modbus.WithRetries(3),
modbus.WithRetryDelay(500*time.Millisecond),
modbus.WithLogger(logging.NewDefaultLogger(logging.WithLevel(logging.LevelInfo))),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
regs, err := client.ReadHoldingRegisters(ctx, 0, 10)
inputs, err := client.ReadInputRegisters(ctx, 0, 5)
coils, err := client.ReadCoils(ctx, 0, 16)
dis, err := client.ReadDiscreteInputs(ctx, 0, 8)
Write
err := client.WriteSingleRegister(ctx, 100, 0x1234)
err = client.WriteMultipleRegisters(ctx, 100, []uint16{0x1111, 0x2222, 0x3333})
err = client.WriteSingleCoil(ctx, 0, true)
err = client.WriteMultipleCoils(ctx, 0, []bool{true, false, true, true})
regs, err := client.ReadWriteMultipleRegisters(ctx, 0, 5, 100, []uint16{42})
Device Identification
devID, err := client.ReadDeviceIdentification(ctx, modbus.ReadDeviceIDBasicStream, modbus.DeviceIDVendorName)
Modbus Types and Constants
modbus.Address
modbus.Quantity
modbus.UnitID
modbus.CoilValue
modbus.RegisterValue
modbus.MaxCoilCount
modbus.MaxRegisterCount
modbus.MaxWriteCoilCount
modbus.MaxWriteRegisterCount
modbus.DefaultTCPPort
modbus.ExceptionFunctionCodeNotSupported
modbus.ExceptionDataAddressNotAvailable
modbus.ExceptionInvalidDataValue
modbus.ExceptionServerDeviceFailure
modbus.IsModbusError(err)
modbus.IsExceptionError(err, modbus.ExceptionDataAddressNotAvailable)
Modbus DataPoints (for plc.PLC / monitor)
modbus.HoldingRegister{Addr: 0, Qty: 10}
modbus.InputRegister{Addr: 0, Qty: 5}
modbus.Coil{Addr: 0, Qty: 16}
modbus.DiscreteInput{Addr: 0, Qty: 8}
EtherNet/IP Client
Connect and Read Tags
ctx := context.Background()
client, err := ethernetip.Connect(ctx, "192.168.1.20",
ethernetip.WithRetries(3),
ethernetip.WithRetryDelay(1*time.Second),
ethernetip.WithLogger(logger),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
data, err := client.ReadTag(ctx, "MyDINT")
var val int32
err = client.ReadTagInto(ctx, "MyDINT", &val)
val, err := ethernetip.Read[int32](client, ctx, "MyDINT")
slice, err := ethernetip.ReadSlice[float32](client, ctx, "MyArray", 10)
data, err = client.ReadTagElements(ctx, "MyArray", 10)
Write Tags
err := client.WriteTag(ctx, "MyDINT", int32(42))
err = client.WriteTag(ctx, "MyREAL", float32(3.14))
err = client.WriteTag(ctx, "MyString", "Hello")
err = client.WriteTag(ctx, "MyBool", true)
Timer and Counter
timer, err := client.ReadTimer(ctx, "MyTimer")
counter, err := client.ReadCounter(ctx, "MyCounter")
var c cip.Counter
err = client.ReadTagInto(ctx, "MyCounter", &c)
Tag Path Syntax
All tag-level APIs (ReadTag, ReadTagElements, ReadTagInto, WriteTag,
ReadTimer, ReadCounter, Read[T], ReadSlice[T], etc.) accept Logix-style
tag strings and translate them into the multi-segment EPATH the controller
requires. Splitting and array indexing is handled by cip.ParseTagPath:
| Syntax | What it addresses |
|---|
MyTag | controller-scoped tag |
MyStruct.Field | struct/UDT member |
MyArray[5] | 1D array element |
Matrix[2,3] | 2D array element |
Program:MainProgram.MyTag | program-scoped tag |
Program:Foo.Bar[5].Baz | program scope + array + member |
Local:2:I.Data[0] | module I/O tag (colons stay in the symbol) |
MyDINT.5 | bit access (BOOL bit 5 of an integer) |
Only . and [...] are separators; : stays inside the symbol because it
appears in program-scope and module I/O tag names. Passing
Program:MainProgram.Tote_Count_CNTR.ACC to ReadTag Just Works.
Discovery
tags, err := client.ListTags(ctx)
items, err := client.ListIdentity(ctx)
services, err := client.ListServices(ctx)
CIP Data Types
| CIP Type | Code | Go Type |
|---|
| BOOL | 0xC1 | bool |
| SINT | 0xC2 | int8 |
| INT | 0xC3 | int16 |
| DINT | 0xC4 | int32 |
| LINT | 0xC5 | int64 |
| USINT | 0xC6 | uint8 |
| UINT | 0xC7 | uint16 |
| UDINT | 0xC8 | uint32 |
| ULINT | 0xC9 | uint64 |
| REAL | 0xCA | float32 |
| LREAL | 0xCB | float64 |
| STRING | 0xD0 | string |
Custom CIP Types (TypeCodec Registry)
Vendor-specific and site-specific struct types (UDTs, AOIs) can be registered
so they decode, encode, and display by name instead of UNKNOWN(0x…).
type SetOn3Timer struct {
PRE int32
ACC int32
EN, EN2, EN3, TT, DN bool
}
func (s *SetOn3Timer) CIPType() cip.DataType { return 0x2F83 }
func (s *SetOn3Timer) String() string { return "SET_ON_3_TMR" }
func (s *SetOn3Timer) UnmarshalCIP(data []byte) error { }
func (s *SetOn3Timer) MarshalCIP() ([]byte, error) { }
func init() {
cip.RegisterType(0x2F83, func() cip.TypeCodec { return new(SetOn3Timer) })
}
cip.DataType(0x2F83).String()
cip.LookupType(0x2F83)
cip.GoTypeToCIPType(&myStruct)
ethernetip.Read[SetOn3Timer](client, ctx, "MyTag")
For well-known Rockwell types, use the vendor/rockwell package:
import "github.com/iceisfun/goindustrial/protocol/ethernetip/vendor/rockwell"
func init() {
rockwell.RegisterTimer(0x02B3)
rockwell.RegisterCounter(0x02B4)
rockwell.RegisterPID(0x02B5)
rockwell.RegisterControl(0x02B6)
}
Available Rockwell types: Timer, Counter, PID, Control.
EtherNet/IP DataPoints (for plc.PLC / monitor)
ethernetip.Tag{Name: "MyDINT", Elements: 1}
ethernetip.Tag{Name: "MyArray", Elements: 10}
PCCC Client (SLC 500 / MicroLogix)
Use when the controller is a legacy Allen-Bradley SLC 5/0x or MicroLogix —
no named tags, only data-table files. PLC-5 word-range commands are out of
scope. Always import alongside protocol/ethernetip:
import (
"github.com/iceisfun/goindustrial/protocol/ethernetip"
"github.com/iceisfun/goindustrial/protocol/ethernetip/pccc"
)
Connect and Read
ctx := context.Background()
eip, err := ethernetip.Connect(ctx, "10.30.40.71")
if err != nil { log.Fatal(err) }
defer eip.Close()
client := pccc.NewClient(eip)
v, err := client.ReadAddress(ctx, "N7:0")
n, err := v.Int()
words, err := client.ReadWords(ctx, "N7:0", 10)
f, err := client.ReadAddress(ctx, "F8:5")
flt, _ := f.Float32()
bit, err := client.ReadAddress(ctx, "B3:0/2")
Write
err := client.WriteAddress(ctx, "N7:0", 42)
err = client.WriteAddress(ctx, "F8:5", float32(3.14))
err = client.WriteAddress(ctx, "B3:0/2", true)
err = client.WriteWords(ctx, "N7:0", []int16{1, 2, 3, -4, 5})
Timer and Counter
v, _ := client.ReadAddress(ctx, "T4:0")
tm, _ := pccc.DecodeTimer(v.Raw)
v, _ = client.ReadAddress(ctx, "C5:0")
c, _ := pccc.DecodeCounter(v.Raw)
v, _ = client.ReadAddress(ctx, "T4:0.ACC")
PCCC DataPoints (for plc.PLC / monitor)
pccc.File{Address: "N7:0"}
pccc.File{Address: "F8:5"}
pccc.File{Address: "B3:0/2"}
pccc.File{Address: "T4:0.ACC"}
pccc.File{Address: "N7:0", Count: 10}
Address syntax
| Form | Meaning |
|---|
N<f>:<e> | integer file f, element e |
F<f>:<e> | float file f, element e (4 bytes) |
B<f>:<e>[/b] | bit file, optional bit b of word |
S:<e> | status file (always file 2) |
I:<e> / O:<e> | input / output image (implicit file 1/0) |
T<f>:<e>.{ACC,PRE,EN,TT,DN} | timer + named bit/sub-element |
C<f>:<e>.{ACC,PRE,CU,CD,DN,OV,UN,UA} | counter |
R<f>:<e>.{LEN,POS,EN,EU,DN,EM,ER,UL,IN,FD} | control |
ST<f>:<e> | string file |
Error classification
*pccc.Error — non-zero PCCC STS or EXT STS. Check with
pccc.IsPCCCError. Not retried.
cip.Error — the PCCC Object returned non-zero CIP general status
(e.g. service not supported on this controller). Not retried.
- Transport errors — handled by the underlying
ethernetip.Client with
its configured WithRetries / WithRetryDelay.
Low-level escape hatch
cmd, _ := pccc.EncodeTypedRead(0x1234 , 2 , 7, pccc.FileTypeInteger, 0, 0)
rawReply, _ := eip.ExecutePCCC(ctx, cmd)
reply, _ := pccc.DecodeReply(rawReply)
Requestor ID (optional)
eip, _ := ethernetip.Connect(ctx, host,
ethernetip.WithCIPVendorID(0xCAFE),
ethernetip.WithCIPSerialNumber(0xDEADBEEF),
)
Reconnecting Transport
Both protocols support the same reconnection pattern via the generic transport layer.
Modbus Reconnecting Client
connector := modbus.NewTCPConnector(host, modbus.WithPort(502))
closer := modbus.NewTCPCloser()
tp := transport.NewReconnectingTransport[*modbus.TCPConn](connector, closer,
transport.WithOnConnect(func() { log.Println("connected") }),
transport.WithOnDisconnect(func(err error) { log.Printf("disconnected: %v", err) }),
)
client := modbus.NewClient(tp,
modbus.WithRetries(3),
modbus.WithRetryDelay(2*time.Second),
modbus.WithLogger(logger),
)
defer client.Close()
EtherNet/IP Reconnecting Client
client := ethernetip.NewReconnectingClient("192.168.1.20",
ethernetip.WithRetries(-1),
ethernetip.WithRetryDelay(2*time.Second),
ethernetip.WithLogger(logger),
)
defer client.Close()
connector := ethernetip.NewSessionConnector("192.168.1.20", logger)
closer := ethernetip.SessionCloser{}
tp := transport.NewReconnectingTransport[*ethernetip.Session](connector, closer,
transport.WithOnConnect(func() { log.Println("session registered") }),
transport.WithOnDisconnect(func(err error) { log.Printf("session lost: %v", err) }),
)
client := ethernetip.NewClient(tp,
ethernetip.WithRetries(-1),
ethernetip.WithLogger(logger),
)
Error Classification
Both protocols distinguish protocol errors from transport errors:
- Protocol errors (Modbus exceptions, CIP status errors) are NOT retried -- the device understood the request and rejected it.
- Transport errors (TCP broken pipe, timeout, EOF) trigger
Reset() + reconnect + retry.
if modbus.IsModbusError(err) {
} else {
}
Monitor
The monitor polls any plc.Reader (both clients implement this) and emits events.
connected := make(chan struct{})
tp := transport.NewReconnectingTransport[*modbus.TCPConn](connector, closer,
transport.WithOnConnect(func() { close(connected) }),
)
m, err := monitor.NewMonitor(client,
monitor.WithLogger(logger),
monitor.WithEventBuffer(128),
monitor.WithConnected(connected),
)
defer m.Close()
sub, err := m.Subscribe(modbus.HoldingRegister{Addr: 0, Qty: 10},
monitor.WithFrequency(100*time.Millisecond),
monitor.WithReadVariance(10*time.Millisecond),
monitor.WithChangeDetector(monitor.ByteChangeDetector{}),
monitor.WithHandler(func(snap monitor.Snapshot) {
fmt.Printf("callback: %s = %x\n", snap.Point, snap.Value.Raw)
}),
monitor.WithInitialRead(0),
)
for evt := range m.Events() {
if evt.Err != nil {
log.Printf("poll error: %v", evt.Err)
continue
}
if evt.Changed {
fmt.Printf("[%s] %s: %x\n", evt.Snapshot.Timestamp.Format(time.RFC3339),
evt.Snapshot.Point, evt.Snapshot.Value.Raw)
}
}
Monitor Subscribers (Broadcast Fan-Out)
sub, err := mon.NewSubscriber(128)
if err != nil {
log.Fatal(err)
}
defer sub.Done()
for evt := range sub.All() {
if evt.Err != nil {
continue
}
if evt.Changed {
fmt.Printf("%s = %x\n", evt.Snapshot.Point, evt.Snapshot.Value.Raw)
}
}
Multiple subscribers each receive all events (broadcast). Each has its own buffered channel — a slow subscriber never blocks the monitor or others.
Adaptive Read Clustering
Wrap a Modbus client in ClusteringReader to coalesce nearby addresses into block reads:
clustered := monitor.NewClusteringReader(modbusClient,
monitor.WithGapThreshold(32),
monitor.WithMaxRegistersPerRead(120),
monitor.WithMaxCoilsPerRead(2000),
monitor.WithCacheTTL(200*time.Millisecond),
)
mon, _ := monitor.NewMonitor(clustered)
The Clusterable interface allows any data point type to participate in clustering. All four Modbus types (HoldingRegister, InputRegister, Coil, DiscreteInput) implement it. Coil extraction handles bit-level packing correctly.
Singleflight deduplication prevents concurrent subscription goroutines from issuing duplicate reads for the same cluster.
Implicit I/O (Cyclic Messaging)
IOScanner (Client Side)
scanner, err := ethernetip.NewIOScanner(session, ":0")
if err != nil {
log.Fatal(err)
}
conn, err := scanner.Open(ctx, ethernetip.IOConnectionConfig{
OTConnectionPoint: 2,
TOConnectionPoint: 1,
OTSize: 12,
TOSize: 12,
RPI: 10 * time.Millisecond,
TimeoutMultiplier: 3,
TargetAddr: targetUDPAddr,
})
if err != nil {
log.Fatal(err)
}
conn.SetOutput([]byte{0x02, 0x00, 0xE8, 0x03, ...})
input := conn.Input()
age := conn.InputAge()
scanner.Close(ctx, conn)
scanner.Shutdown(ctx)
Adapter (Server Side)
ao := assembly.NewAssemblyObject()
ao.RegisterAssembly(100, make([]byte, 12))
ao.RegisterAssembly(101, make([]byte, 12))
rt := runtime.NewRuntime(ao)
rt.Start(":2222")
sched := runtime.NewScheduler(rt)
sched.Start()
cm := connmgr.NewConnectionManager(
connmgr.WithOnOpen(func(c *connmgr.Connection, req *connmgr.ForwardOpenRequest) {
rpi := time.Duration(req.OTRPI) * time.Microsecond
rt.AddConnection(&runtime.IOConnection{
ConnectionID: c.OTConnectionID, RPI: rpi,
Assembly: ao.GetInstance(100), IsConsumer: true,
})
rt.AddConnection(&runtime.IOConnection{
ConnectionID: c.TOConnectionID, RPI: rpi,
Assembly: ao.GetInstance(101), IsProducer: true,
})
}),
connmgr.WithOnClose(func(c *connmgr.Connection) {
rt.RemoveConnection(c.OTConnectionID)
rt.RemoveConnection(c.TOConnectionID)
}),
)
router := cip.NewMessageRouter()
router.RegisterObject(cip.ClassConnectionMgr, cm)
srv := ethernetip.NewServer(router)
srv.Start(ctx, ":44818")
Modbus Server
store := modbus.NewMemoryStore()
store.SetHoldingRegister(0, 1234)
store.SetCoil(0, true)
store.SetInputRegister(0, 5678)
store.SetDiscreteInput(0, true)
srv := modbus.NewServer("0.0.0.0",
modbus.WithServerPort(502),
modbus.WithServerLogger(logger),
modbus.WithServerDataStore(store),
modbus.WithOnClientConnect(func(c modbus.ConnectedClient) {
log.Printf("client connected: %s", c.RemoteAddr)
}),
modbus.WithOnClientDisconnect(func(c modbus.ConnectedClient) {
log.Printf("client disconnected: %s (rx=%d tx=%d)", c.RemoteAddr, c.RxTransactions, c.TxTransactions)
}),
)
srv.Start(ctx)
defer srv.Stop(ctx)
ds := srv.GetDataStore()
EtherNet/IP Server
router := cip.NewMessageRouter()
router.RegisterObject(cip.UINT(0x04), myCustomObject)
srv := ethernetip.NewServer(router,
ethernetip.WithServerLogger(logger),
ethernetip.WithIdentity(eip.ListIdentityItem{
TypeID: eip.ItemIDListIdentity, EncapsVersion: 1,
VendorID: 1, ProductName: "My Device",
}),
ethernetip.WithOnClientConnect(func(c ethernetip.ConnectedClient) {
log.Printf("client connected: %s", c.RemoteAddr)
}),
ethernetip.WithOnClientDisconnect(func(c ethernetip.ConnectedClient) {
log.Printf("client disconnected: %s", c.RemoteAddr)
}),
)
srv.Start(ctx, ":44818")
defer srv.Stop()
clients := srv.ConnectedClients()
A cip.Object must implement:
type Object interface {
HandleRequest(service cip.USINT, path cip.Path, data []byte) ([]byte, error)
}
Return cip.Error{Status: cip.StatusPathDestinationUnknown} for unsupported requests.
plc.PLC Interface
Both clients implement this protocol-agnostic interface:
type PLC interface {
Read(ctx context.Context, points ...DataPoint) ([]Value, error)
Write(ctx context.Context, point DataPoint, data []byte) error
Connect(ctx context.Context) error
Disconnect(ctx context.Context) error
IsConnected() bool
}
Use it when writing code that should work with any protocol:
func pollAndLog(ctx context.Context, p plc.PLC, dp plc.DataPoint) {
vals, err := p.Read(ctx, dp)
if err != nil {
log.Printf("read error: %v", err)
return
}
log.Printf("%s = %x", dp, vals[0].Raw)
}
Logging
logger := logging.NewDefaultLogger(
logging.WithLevel(logging.LevelDebug),
)
logger := logging.NewNopLogger()
All log methods take context.Context as first parameter:
logger.Info(ctx, "connected to %s", addr)
logger.WithFields(map[string]any{"unit": 1}).Debug(ctx, "reading registers")
Hex Dump Tracing
Both protocols support wire-level hex dump tracing via WithHexDump(io.Writer):
client, err := modbus.Connect(ctx, "192.168.1.10",
modbus.WithHexDump(os.Stdout),
)
f, _ := os.Create("trace.hex")
client, err := ethernetip.Connect(ctx, "192.168.1.20",
ethernetip.WithHexDump(f),
)
client, err := modbus.Connect(ctx, "192.168.1.10",
modbus.WithHexDump(io.MultiWriter(os.Stdout, f)),
)
Output format (hexdump -C style, short lines padded for column alignment):
>>> WRITE 12 bytes
00000000 00 00 00 00 00 06 01 03 00 00 00 03 |............ |
<<< READ 15 bytes
00000000 00 00 00 00 00 09 01 03 06 00 01 00 02 00 03 |............... |
The hexdump.Dumper type can also be used directly to wrap any io.Reader or io.Writer:
d := hexdump.NewDumper(os.Stdout)
wrappedReader := d.WrapReader(someReader)
wrappedWriter := d.WrapWriter(someWriter)
Testing with net.Pipe
Both protocols support injecting a net.Conn for in-process testing:
serverConn, clientConn := net.Pipe()
srv := modbus.NewServer("", modbus.WithServerConn(serverConn))
srv.Start(ctx)
client, _ := modbus.Connect(ctx, "", modbus.WithConn(clientConn))
router := cip.NewMessageRouter()
srv := ethernetip.NewServer(router, ethernetip.WithServerConn(serverConn))
srv.Start(ctx, "")
conn, _ := ethernetip.NewTCPConn("", ethernetip.WithConn(clientConn))
Lua Scripting Bindings (Optional)
The lua/ package provides GoLua v2 (Lua 5.5.0) bindings. Import it separately — the core library remains zero-dependency.
import (
"github.com/iceisfun/golua/v2/vm"
"github.com/iceisfun/golua/v2/stdlib"
industrialLua "github.com/iceisfun/goindustrial/lua"
)
v := vm.New()
stdlib.Open(v)
industrialLua.Open(v)
Lua Modbus API
local client = modbus.connect("192.168.1.10", {
port = 502, unit = 1, retries = 2, timeout = 5
})
local regs = client:read_holding_registers(0, 10)
local coils = client:read_coils(0, 8)
local inputs = client:read_input_registers(0, 5)
local discrete = client:read_discrete_inputs(0, 8)
client:write_register(100, 0x1234)
client:write_registers(100, {10, 20, 30})
client:write_coil(0, true)
client:write_coils(0, {true, false, true})
local result = client:read_write_registers(0, 5, 100, {42})
local int32_val = client:to_int32(regs[1], regs[2])
local float32_val = client:to_float32(regs[1], regs[2])
local dev = client:read_device_id()
print(dev.vendor_name, dev.product_code, dev.revision)
client:close()
Lua EtherNet/IP API
local client = eip.connect("192.168.1.20:44818", {
retries = 2, timeout = 10
})
local val = client:read_tag("MyDINT")
local fval = client:read_tag("MyREAL")
local values = client:read_tags({"Tag1", "Tag2", "Tag3"})
client:write_tag("MyDINT", 42)
client:write_tag("MyREAL", 3.14)
client:write_tag("MyINT", 100, "INT")
local timer = client:read_timer("MyTimer")
print(timer.pre, timer.acc, timer.en, timer.tt, timer.dn)
local counter = client:read_counter("MyCounter")
print(counter.pre, counter.acc, counter.cu, counter.dn)
local tags = client:list_tags()
for i = 1, #tags do
print(tags[i].id, tags[i].name, tags[i].type)
end
client:close()
Error Handling in Lua
All protocol errors raise Lua errors. Use pcall for safe calls:
local ok, err = pcall(function()
client:read_tag("NONEXISTENT_TAG")
end)
if not ok then
print("Error:", err)
end
Examples
Runnable examples under examples/ covering every operation, each with its own README:
Modbus: read_registers, write_registers, read_coils, write_coils, read_write_registers, device_identification, server, reconnecting, all_data_types, hexdump
EtherNet/IP: read_tag, write_tag, read_tag_typed, timer_counter, list_tags, list_identity, server, reconnecting, probe, adapter, io_scanner, hexdump, custom_type
Cross-protocol: monitor_polling, monitor_subscriber, plc_interface
Lua scripting: lua/modbus_client, lua/ethernetip_client, lua/monitor_tags, lua/condition_monitor
Run any example:
go run ./examples/modbus/server/ -port 5020
go run ./examples/modbus/read_registers/ -addr 127.0.0.1 -port 5020
go run ./examples/ethernetip/read_tag/ -addr 192.168.1.10:44818 -tag MyDINT
Guidance For AI Assistants
Good defaults:
-
Use modbus.Connect or ethernetip.Connect for simple scripts; build the transport manually only when you need lifecycle hooks or custom reconnection behavior.
-
Use protocol-specific methods (ReadHoldingRegisters, ReadTag) for protocol-aware code; use plc.PLC for protocol-agnostic code.
-
Always pass context.Context and handle timeouts with context.WithTimeout.
-
Check modbus.IsModbusError(err) before assuming a transport failure.
-
Use ethernetip.Read[T] generics for typed reads; use ReadTagInto when you have a pointer to unmarshal into.
-
For EIP, ReadTag returns raw bytes with a 2-byte type code prefix; ReadTagInto strips it automatically.
-
Timer and Counter are 14-byte Rockwell structures: Reserved(2) + StatusBits(4) + PRE(4) + ACC(4).
-
For vendor-specific struct types (UDTs, AOIs), implement cip.TypeCodec and call cip.RegisterType at init() time. Use vendor/rockwell for known Rockwell types. Type codes are controller-specific — discover them via ListTags.
-
WithRetries(-1) means infinite retries for long-running applications.
-
Both servers support WithServerConn(net.Conn) for deterministic in-process testing with net.Pipe.
-
The monitor works with any plc.Reader -- you can poll Modbus and EtherNet/IP through the same monitor.
-
For implicit I/O, use IOScanner (client) or the adapter pattern (server). The adapter wires ConnMgr callbacks to Runtime — Forward_Open automatically creates I/O connections.
-
plc.Value now carries Type and ByteOrder metadata. Use val.Int(), val.Float32(), etc. instead of manual binary decoding.
-
Monitor subscribers (NewSubscriber) use iter.Seq[Event] for for-range loops. Each subscriber gets all events independently (broadcast).
-
Use the probe example to discover what a device supports before building integrations.
-
For Lua bindings, industrialLua.Open(v) registers both modbus and eip globals. Lua methods use colon syntax (client:read_tag("name")); the self parameter is handled automatically.
-
Lua errors from protocol operations are raised via panic and caught by pcall in Lua.
The goal of this skill is to help an assistant build correct integrations quickly using the public API.