EtherNet/IP
A driver: {type: eip} section puts a manifest project on an Allen-Bradley
Logix controller — ControlLogix, CompactLogix — over a pure-Go CIP stack: no
cgo, no RSLinx, no OPC server in the middle. Nothing about the controller is
typed by hand: naut eip import browses the live PLC and writes the UDT
shapes, the tag bindings, and the tag file. Polling policy is the part you
author.
driver: type: eip host: 192.168.1.10 slot: 0 # processor backplane slot (default 0) manifest: eip_manifest.yaml # from `naut eip import --format yaml` scan-rate: 500ms # the default scan class (driver default 250ms) scan-classes: { fast: 100ms, slow: 10s } tag-classes: fast: ["Line1_PIT_*"] slow: ["*_Totals"]tag-files: [tags/eip.yaml]host: and manifest: are both required, and the manifest is decoded
strictly — a misspelled key is an error, not a silently dropped binding. All
of that fails naut check, not naut run, because New never
dials: it decodes the manifest, resolves every type reference, rejects
duplicate names and partitions the bindings into scan classes, all offline.
The connection is Start’s job, so naut check and
naut build pass in CI with no controller in sight. examples/batch-skid
(line.yaml) is a complete manifest project driving an “existing” Logix
line controller over this driver — see its README for the read/write
handshake and naut logix emulate, the hardware-free way to run it.
Generating the manifest: naut eip import
Section titled “Generating the manifest: naut eip import”Point the importer at the controller: it walks the Symbol class in every scope, uploads the templates those tags depend on, and generates source:
naut eip browse --host 192.168.1.10 # what's on the controllernaut eip import --host 192.168.1.10 \ --tags 'Line1*,Program:MainProgram.*' \ --writable 'Line1Cmd*' \ --format yamlbrowse prints one line per tag — name, type name (the UDT’s own, or the
elementary type) and array dimensions — which is how you find the patterns
worth importing. Both commands take --slot and --port (default 44818).
import always writes eip_types.st. With --format yaml it also writes
eip_manifest.yaml and tags/eip.yaml; with the default --format go it writes eip_manifest.go instead (a var EIPManifest = eip.Manifest{…} for an SDK project, named by --package). All are committed,
never hand-edited, and carry a header saying so with the command that
regenerates them.
eip_manifest.yaml—types:(name,fields:withname/type/arraylen) andtags:(name,device,type,arraylen,writable,scanclass). This is whatmanifest:names.tags/eip.yaml— an ordinary tag file:role: input, orrole: outputfor a binding matched by--writable, plustype:naming the UDT for struct bindings, and the driver’s__Onlinecompanion as a BOOL input. Compose it withtag-files:.
The companion is named after the driver the project will run this
controller with, the way the loader names it. --name sets it. Otherwise
the import reads nautilus.yaml in --out and takes the eip driver
whose host: is --host (with or without a port): eip for a driver:
section, the entry’s name: (or eip-2, …) in a drivers: list. A
project with one eip driver uses it whatever its host. A project with
several and none on this host is an error that lists them; pass --name.
With no nautilus.yaml, or no eip driver in it yet, the name is eip.
The import prints the tag it used (online tag: line2__Online (…)), and the
Go form puts eip.WithOnlineTag("…") in the wiring comment of
eip_manifest.go.
Useful flags: --tags selects device tags by comma-separated globs (default:
every user tag except module I/O like Local:1:I, which needs an explicit
pattern), --writable marks the ones the program commands, --out picks the
output directory, --tags-out the tag file path, and --tags-skip leaves a
tag out of the tag file when the project declares it by hand — the escape
hatch for the one thing the import cannot know, an init: on an input.
Descriptions are deliberately not generated, because Logix keeps them in the
offline project file where a CIP browse cannot reach them: put those in
nautilus.yaml’s tag-meta:, which reaches only unit and desc, so
regenerating never argues with them.
naut eip tags eip_manifest.yaml -o tags/eip.yaml re-derives just the
tag file from the already-committed manifest — a pure function of the repo,
no controller needed, so it runs in review or in CI. It names the companion
the same way, from nautilus.yaml in the working directory, finding the
driver by its manifest: (or by -host), and takes --name too. Its -skip takes the
same globs as --tags-skip, and a skip pattern matching nothing is an error
rather than a silent regeneration of a hand-written tag.
At every connect the driver validates the manifest against the live controller: a bound tag that no longer exists, a tag whose template is now a different type, a UDT that lost a member. Drift fails the connection with every problem listed and a pointer at re-import — it never mis-decodes a changed program into plausible numbers.
Types: UDTs become ST TYPEs
Section titled “Types: UDTs become ST TYPEs”eip_types.st is a real ST TYPE block, generated through the same builder
the compiler validates, so a UDT is a first-class struct in your program
rather than flattened scalars:
TYPE Analog_Input : STRUCT AI : REAL; SIGNALFAIL : BOOL; LOWSCL : REAL; END_STRUCT;END_TYPE
(* in your program *)VAR_EXTERNAL RTU60_ZONE13_PIT_001 : Analog_Input;END_VARIF NOT RTU60_ZONE13_PIT_001.SIGNALFAIL THEN Pressure := RTU60_ZONE13_PIT_001.AI;END_IF;Member names are the controller’s, verbatim, in template wire order — nested
UDTs are emitted dependencies-first so the block compiles top to bottom, and a
ready-to-paste VAR_EXTERNAL block for every binding is appended as a
comment. A template with the Logix string shape (a DINT LEN plus a
SINT[] DATA) becomes the type STRING, not a struct. Array members become
ARRAY[0..n-1]. STIME, DATE and TIME_OF_DAY map to DINT and
DATE_AND_TIME to LINT — ST has no direct equivalent, and those match the
width.
Tag names are sanitized into identifiers: Program:MainProgram.Motor becomes
MainProgram_Motor, every other non-alphanumeric becomes _, runs collapse,
a leading digit gets a Tag_ prefix, and a collision gets _2; the device
path stays in the manifest’s device:, so the mapping stays visible. What
does not come across is reported, not guessed — multi-dimensional arrays,
members whose type code the stack does not decode, and templates that could
not be uploaded print as skipped: lines with a reason and never reach the
manifest.
Scan classes and leaf mode
Section titled “Scan classes and leaf mode”Every class shares one connection. A binding’s class comes from the
manifest’s scanclass, overridden by tag-classes: globs matched against
either the nautilus tag name or the device path (later assignments win);
anything unassigned is the default class, whose rate is scan-rate.
Naming a class without defining its rate is a load error. The reserved class
none catalogs a tag without ever polling it — a command tag the program
only writes belongs there, and it stays a valid write target. Every class is
polled once immediately on connect, so the snapshot fills without waiting out
a slow class. How a binding is read depends on its shape:
- Elementary scalars are batched into Multiple Service Packets, sized to the negotiated Class 3 connection (500 bytes), so a class of two hundred DINTs and REALs costs a handful of round trips rather than two hundred.
- Structs, strings and arrays are read whole — one Read Tag for the root — and fall back to Read Tag Fragmented transparently when the value outgrows the connection.
- Leaf mode is the fallback for a struct root the controller refuses. Logix answers a whole-struct read on a tag whose type carries access-restricted members (AOI backing tags, most often) with a privilege violation. That binding then drops to leaf mode for the life of the session: the manifest type is flattened into its elementary members, every member is read individually (batched, same as scalars), and the struct is assembled client-side in manifest field order.
Inside leaf mode, a member the controller will never serve — an internal
word like TIMER.Control, anything the UDT marks external-access none — is
recognized by its CIP status, logged once for the whole tag, dropped from the
poll set, and assembled as the IEC zero for its type. A member that fails for
any other reason holds the binding’s last good value for that cycle: a
partial struct is never published. /api/drivers counts the bindings in leaf
mode as leaf structs.
The __Online companion
Section titled “The __Online companion”The driver synthesizes one BOOL input for the controller’s connection,
named after the driver: eip__Online for a driver: section, or
<name>__Online for a named entry in a drivers: list (name: line2 gives
line2__Online; characters an identifier can’t hold become _, so the
default eip-2 gives eip_2__Online). naut eip import and naut eip tags write it into tags/eip.yaml as a BOOL input, so it needs no
declaration of its own (see Generating the
manifest for how the import
finds the name).
It is true while the controller is connected and every scan class has completed a poll on this connection. It is false before the first connect, while reconnecting, and between a reconnect and its first full read. It is the Logix connection-fault bit, inverted, and the tag to check before trusting this controller’s values:
Permissive := eip__Online AND Line1_Ready;A controller that is down when naut starts does not fault the programs
that read its tags. Until the first poll each input reads as its init:,
or the zero of its type, and its quality is NotConnected (see Inputs
from a device that is
down). Once started,
the driver’s read never fails: before this, a controller that was down at
boot failed the whole input read, so in a drivers: list it starved every
other driver’s inputs too. Before Start the read still errors, so a
driver that was never started fails loudly.
Per-tag quality
Section titled “Per-tag quality”The driver implements io.QualityReporter, non-Good entries only:
- an input the controller has never delivered (not connected yet, or a
tag whose reads fail) is
NotConnected; - an input with a value on file while the controller is not connected
is
Stale: its last field value, held; - otherwise it is
Good(omitted). The__Onlinecompanion is the driver’s own truth and is always Good.
Before this, the eip driver reported no quality of its own. After a
connection dropped, tags kept reading Good while holding their last
values. A dotted path resolves to its root tag: a UDT arrives whole, so
P101.Drive.Speed is exactly as trustworthy as P101.
Writes
Section titled “Writes”A binding marked writable in the manifest is a role: output tag. The
runtime pushes output changes — the first scan, then any scan where an output
moved — and the driver diffs again against its own last-written set, so an
unchanged value never reaches the wire twice. Queued writes flush between
polls on the same connection: a write kicks the poll loop awake rather than
waiting out the next interval.
- An elementary scalar goes as one CIP Write Tag with its Logix type code.
- A struct is written member by member, symbolically
(
Device.Member.Sub), and only the members that actually changed since the last write — every member on the first write. Nested structs recurse. - Strings and array members inside a struct are not written, and a
top-level
STRINGor array binding is not a writable target either.
There is no baseline rule here. Where the Modbus and Sparkplug-host
drivers record the first snapshot after Start and send it to nobody, this
driver commands what the first scan produces — an output tag’s init: reaches
the controller. Give every output an init: that is safe to command.
A write that fails because the connection broke is requeued and retried after reconnect, latest value per tag. A write the controller refuses on a live connection is logged and dropped for that flush; it goes again the next time the value changes.
Writes are not gated. The driver has no SetWriteGate — the redundancy
hook the Modbus driver exposes — so nothing inside it suppresses a command.
Redundancy is handled a level up: a standby replica skips scans entirely, so
it never produces an output to hand over, while its driver keeps polling.
/api/drivers
Section titled “/api/drivers”Kind: "ethernet-ip", one row per driver, named by host, with
Detail: "192.168.1.10 · slot 0". The state is connected (message
Polling N tags), connecting, error (Connect failed — retrying, with
lastError), or degraded — a connection that is up while every poll fails.
Metrics: tags, polls, poll errors, reconnects, leaf structs when any
binding is in leaf mode, and last poll as a moment the client renders an age
from. Reconnects back off from 1s to roughly 30s, reset on success.
No PLC? The Logix emulator
Section titled “No PLC? The Logix emulator”naut logix emulate stands a ControlLogix up on your laptop from the
project’s own L5X export — the same file naut logix import reads:
naut logix emulate --l5x UpstreamLine.L5X --ramp# emulating ControlLogix "UpstreamLine" on 127.0.0.1:44818 — 4 tags (1 programs), 1 templates, 9 leavesEvery DataType and Add-On Instruction becomes a template (a UDT’s BIT
overlays are served as the BOOLs they were authored as; STRING, TIMER and
COUNTER are supplied when used), every controller tag becomes a symbol, and
every program tag is served as Program:<prog>.<tag>, exactly as the real
controller lists it. Initial values come from the export’s decorated <Data>.
Aliases, multi-dimensional arrays, and types with no public shape (MESSAGE,
AXIS_*) are left out, and the emulator prints each one it skipped.
| Flag | |
|---|---|
--l5x <file.L5X> | Derive the tag surface from an export (whole controller or a partial program export) |
--surface <file.json> | Or serve a hand-written surface: the JSON logixserver.LoadTagSurface reads (naut logix emulate -h shows its shape) |
--listen | Address to serve on (default 127.0.0.1:44818; port 0 picks a free one) |
--values seed.json | {"path": value} seeds applied after load, paths as a client reads them: {"Line_Rate": 12.5, "Handshake.Ready": true, "Program:MainProgram.Step": 3}. An object seeds members, an array elements, a string a STRING tag |
--ramp | Drift every numeric leaf slowly so the HMI visibly moves; a leaf a client writes stops drifting and keeps the write |
--name | Controller name to report (default: the export’s) |
Client writes land in the emulator, so a project’s outputs read back on the
next poll. Point the tools and the project at it — a non-default port goes on
host: as host:port:
naut eip browse --host 127.0.0.1naut eip import --host 127.0.0.1 --format yaml --writable 'Batch_Ack'driver: type: eip host: 127.0.0.1 # or 127.0.0.1:44819 for a second emulator manifest: eip_manifest.yamltag-files: [tags/eip.yaml]Under the CLI is eip/logixserver, an in-repo ControlLogix target:
Forward_Open, symbolic Read/Write Tag (plain and fragmented), Multiple Service
Packet batches, tag-list upload, the Template services, and the Identity and
Program Name objects — the surface both nautilus’s own client and pycomm3
speak. As a library it runs from a few lines, which is how tests drive it:
package main
import ( "context" "log"
ls "github.com/joyautomation/nautilus/eip/logixserver")
func main() { // The tag surface is declarative: templates, symbols, and the leaf paths // that resolve. Instance ids, symbol-type bits and member offsets are the // loader's job. ls.LoadTagSurface reads the same shape from a JSON file. schema, tags, name, err := ls.CompileSurface(&ls.TagSurfaceSpec{ ControllerName: "TestController", Templates: []ls.TemplateSpec{{Name: "Header_Type", Members: []ls.MemberSpec{ {Name: "Displacement", Datatype: "REAL"}, {Name: "Valid", Datatype: "BOOL"}}}}, Symbols: []ls.SymbolSpec{{Name: "Speed", Datatype: "REAL"}, {Name: "TRS", Datatype: "Header_Type"}}, Tags: []ls.TagSpec{{Path: "Speed", Datatype: "REAL"}, {Path: "TRS.Displacement", Datatype: "REAL"}, {Path: "TRS.Valid", Datatype: "BOOL"}}, }) if err != nil { log.Fatal(err) } store := ls.NewTagStore() for _, t := range tags { store.Set(t.Path, t.LeafType, t.Default) } store.UpdateValue("Speed", 42.5) // drive values while it runs log.Fatal(ls.NewServer(store, schema, name, ":44818", nil).Run(context.Background()))}ls.SurfaceFromL5X is the L5X derivation the CLI uses, and ls.SeedValue
its seeding. Either way the whole browse → import → poll → write path runs
with nothing on the network. CI exercises exactly this — go test ./...
runs the driver, the logix client and the codegen against the emulator on
every push, no build tags, no env gating — and its DenyStructRoots switch
refuses struct roots the way a real controller refuses an AOI backing tag,
which is how leaf mode is tested.
Testing
Section titled “Testing”naut test never opens a socket: whatever driver: configures, every
acceptance test substitutes the in-memory loopback driver, so an EtherNet/IP
project is fully testable on a laptop with nothing on the network, no separate
config needed.
naut check . && naut test .given: writes the driver’s input image exactly the way a poll would —
including struct bindings field by field, using the type: the generated tag
file carries — so the same *_test.yaml files pass unchanged once the real
driver is polling. Suites run in virtual time, so a ten-second interlock or a
loop’s settling time is asserted deterministically, in milliseconds. -m
takes a second project file if you want one for logic-only runs
(naut test -m memory.yaml .).
Not supported
Section titled “Not supported”- Non-Logix CIP devices. The driver speaks Logix symbolic tag access
(Read/Write Tag, fragmented variants, Multiple Service Packet) over a
Class 3 connection to the Message Router. A drive, valve, or I/O block with
no tag database is not addressable through it, and generic
class/instance/attribute messaging — which
eip/cipcan encode — is not exposed as a binding. - Implicit (Class 1) cyclic I/O.
eip/cipcarries a Class 1 producer/consumer and an originator, but nothing in the driver wires them: an I/O assembly connection is not a thing a manifest can declare. - PCCC / SLC / PLC-5 and Micro800. There is no PCCC encapsulation in the tree at all.
- Multi-dimensional arrays. A one-dimensional array tag binds as a whole;
a
[10,10]tag is skipped by the import with a reason. - Writing strings and arrays. They poll fine; the write path covers only elementary scalars and UDT structs.
From Go
Section titled “From Go”The driver: section is the manifest form of the eip package. An SDK
project (naut new --template sdk, --format go on the import) wires it
directly as an io.Driver:
import "github.com/joyautomation/nautilus/eip"
driver, err := eip.New("192.168.1.10", EIPManifest, eip.WithSlot(0), // WithPort overrides 44818 // Polling policy is configuration, not codegen: re-running the import // refreshes the tag catalog without touching any of this. eip.WithScanRate(500*time.Millisecond), // the default class eip.WithScanClass("fast", 100*time.Millisecond), eip.WithScanClass("slow", 10*time.Second), eip.WithTagClass("fast", "Line1_PIT_*"), // globs on name or device path eip.WithTagClass("slow", "*_Totals"), eip.WithTagClass(eip.NoPoll, "Line1Cmd*"), // cataloged + writable, never polled eip.WithLogger(slog.Default().With("driver", "eip")), eip.WithOnlineTag("eip__Online"), // the connection-status input)driver.Start(ctx)
rt, err := runtime.New(runtime.Options{ Program: program, Driver: driver, Inputs: driver.InputNames(), Outputs: driver.OutputNames(),})EIPManifest is the generated eip.Manifest value — the same structure the
project loader decodes from eip_manifest.yaml, so a catalog computed rather
than configured can be built in code. ScanClasses() returns the resolved
poll groups (class → tag names) for diagnostics and Health() the row behind
/api/drivers. Stop cancels the loop and waits for it to close the
connection; New never opened one.