The adapter: ciplink as an I/O device¶
By default ciplink is a client: it connects to PLCs and reads and writes their tags, and it listens on no
EtherNet/IP port. The adapter is the opposite role, and it is opt-in. An application that starts one becomes an
EtherNet/IP device that a Logix controller can find in RSWho and own in its I/O tree, like a camera or an I/O
module. Nothing listens until the application starts an Adapter, so programs that only use the client never
see it.
Use it when a ciplink application is a service host the PLC should talk to directly, e.g. a vision station that takes a trigger from the PLC and returns results every RPI.
What it does¶
- Class 1 I/O. Answers Forward Open from a scanner. It produces input assemblies to the PLC every RPI (T→O) and consumes output assemblies from it (O→T, with run/idle). The adapter accepts exclusive-owner, input-only and listen-only connections, with multicast or point-to-point T→O. The I/O runs on its own threads, so a slow application never makes a packet late.
- Explicit messages. Get/Set of assembly data, and the Identity, Port, TCP/IP Interface and Ethernet Link objects that RSWho and Studio 5000 read.
- Discovery. Answers ListIdentity, so the device shows up when RSWho, Linx or ciplink's
browse_portandscan_portbrowse its subnet. - EDS upload. The File object serves the EDS, if the application gives one, so "Upload EDS from device" works.
- The ciplink object (class 0x64). It marks the device as ciplink and describes the application, so ciplink's own browser can show it as a ciplink device.
The application reads and writes assembly data and takes events from a queue (next_event): connection opened,
closed or timed out, new output data, and new config data.
use ciplink::adapter::{Adapter, AdapterConfig, Assembly, Event};
use std::time::Duration;
let adapter = Adapter::start(AdapterConfig {
assemblies: vec![Assembly::input(100, 148), Assembly::output(150, 32), Assembly::config(199, 0)],
app_name: "Station".into(),
..AdapterConfig::default()
})?;
adapter.set_input(100, &[1, 2, 3])?;
while let Some(event) = adapter.next_event(Some(Duration::from_secs(1))) {
match event {
Event::Opened(conn) => println!("{:?} opened by {}", conn.kind, conn.originator),
Event::Output { instance } => {
let output = adapter.output(instance)?;
println!("trigger {} (run {})", output.data[0] & 1, output.run);
}
Event::TimedOut(conn) => println!("{} stopped talking", conn.originator),
_ => {}
}
}
from ciplink.adapter import Adapter
with Adapter(inputs={100: 148}, outputs={150: 32}, configs={199: 0}, app_name="Station") as adapter:
adapter.set_input(100, bytes([1, 2, 3]))
while True:
event = adapter.next_event(1.0) # a dict, or None after 1 s
if event and event["event"] == "opened":
c = event["connection"]
print(c["kind"], "opened by", c["originator"])
elif event and event["event"] == "output":
data, run = adapter.output(event["instance"])
print("trigger", data[0] & 1, "run", run)
The I/O runs on the core's threads, so a slow Python loop never makes a packet late. identity={...} sets
the Identity object, RPIs are in seconds, and the other keywords are those of AdapterConfig.
using Ciplink;
using var adapter = new Adapter(new AdapterConfig
{
Inputs = new Dictionary<uint, int> { [100] = 148 },
Outputs = new Dictionary<uint, int> { [150] = 32 },
Configs = new Dictionary<uint, int> { [199] = 0 },
AppName = "Station",
});
adapter.SetInput(100, new byte[] { 1, 2, 3 });
while (true)
{
var e = adapter.NextEvent(TimeSpan.FromSeconds(1)); // null after 1 s
if (e is null) continue;
if ((string)e["event"]! == "opened")
Console.WriteLine($"{e["connection"]!["kind"]} opened by {e["connection"]!["originator"]}");
else if ((string)e["event"]! == "output")
{
AdapterOutput output = adapter.Output((uint)e["instance"]!);
Console.WriteLine($"trigger {output.Data[0] & 1} (run {output.Run})");
}
}
#include <iostream>
#include "ciplink.hpp"
ciplink::AdapterConfig config;
config.inputs = {{100, 148}};
config.outputs = {{150, 32}};
config.configs = {{199, 0}};
config.app_name = "Station";
ciplink::Adapter adapter(config);
adapter.set_input(100, {1, 2, 3});
for (;;) {
ciplink::Value e = adapter.next_event(1.0); // null after 1 s
if (e.is_null()) continue;
if (e["event"].as_string() == "opened") {
std::cout << e["connection"]["kind"].as_string() << " opened by "
<< e["connection"]["originator"].as_string() << "\n";
} else if (e["event"].as_string() == "output") {
ciplink::AdapterOutput out = adapter.output(uint32_t(e["instance"].as_int()));
std::cout << "trigger " << (out.data[0] & 1) << " (run " << out.run << ")\n";
}
}
#include <stdio.h>
#include "ciplink.h"
CiplinkAdapter *adapter = NULL;
if (ciplink_adapter_start("{\"inputs\": {\"100\": 148}, \"outputs\": {\"150\": 32}, \"configs\": {\"199\": 0},"
" \"app_name\": \"Station\"}", &adapter) != CIPLINK_STATUS_OK) {
char *err = ciplink_last_error(NULL);
fprintf(stderr, "%s\n", err); /* e.g. the port is taken */
ciplink_string_free(err);
return 1;
}
const uint8_t input[] = {1, 2, 3};
ciplink_adapter_set_input(adapter, 100, input, sizeof input);
for (;;) {
char *event = NULL; /* JSON: {"event": "opened", "connection": {...}}, {"event": "output", "instance": 150}, ... */
if (ciplink_adapter_next_event(adapter, 1.0, &event) != CIPLINK_STATUS_OK)
break;
printf("%s\n", event); /* null after 1 s */
ciplink_string_free(event);
uint8_t *data = NULL;
size_t len = 0;
bool run = false;
if (ciplink_adapter_output(adapter, 150, &data, &len, &run) == CIPLINK_STATUS_OK) {
if (len) printf("trigger %d (run %d)\n", data[0] & 1, run);
ciplink_bytes_free(data, len);
}
}
ciplink_adapter_free(adapter); /* stops it */
The config is JSON with the Python binding's keywords (inputs, outputs, configs, identity, app_name,
bind, port, rpi_min ...); events, connections and stats come back as JSON in the same shapes.
ciplink_adapter_stop makes a thread waiting in ciplink_adapter_next_event return within 0.1 s.
In device browsers¶
RSWho and Studio 5000 list the adapter from its standard objects, under its product name (and its EDS icon once the
EDS is registered). ciplink's own browsing also reads the ciplink object of every device it finds at an IP address,
so a running adapter carries an app field: the application's name, version and status text, its assemblies and
connections, and a summary line such as Station · station 2 · owned by 192.0.2.5, RPI 20 ms.
set_detail("station 2") sets the status text. See Discovering and browsing devices.
Adding it to a Logix project¶
You can add the adapter to the I/O tree in either of two ways:
- Generic Ethernet Module (no EDS). Add an ETHERNET-MODULE and enter the instance numbers and sizes:
input 100, output 150, configuration 199 (size 0 if there is no config data). Sizes are in the module's data
format. Size the connection to the assembly sizes in
AdapterConfig; the adapter refuses the connection when they differ. - With an EDS. Register the EDS in Studio 5000 (EDS Hardware Installation Tool, or upload it from the device
in RSWho). It then appears as a named module with its connections listed. An EDS must use its vendor's own
ODVA vendor ID; the default
vendor_id0 is a placeholder.
Input-only and listen-only connections use the heartbeat instances 238 and 237 as their O→T point (configurable
as input_only_heartbeat and listen_only_heartbeat). An EDS has to name the same numbers.
Each output assembly has one owner at a time, so two controllers can each own a different output assembly. If a second controller opens the same output, it is refused with 0x0106 (ownership conflict). A controller that reconnects before its old connection has timed out also gets this refusal, until the old connection times out.
One EtherNet/IP device per IP address¶
The adapter cannot run on the same IP address as another program that listens on the EtherNet/IP ports. On a typical PC, this rules out:
| Program | Why it clashes |
|---|---|
| FactoryTalk Linx, RSLinx Classic | listen on TCP/UDP 44818 (and often UDP 2222) on every network card |
ciplink-sim |
a simulated controller, listening on 44818 |
| a second adapter | each adapter is one device |
The ports are fixed by the protocol, not chosen by ciplink:
- A controller always opens its session to TCP 44818 of the module's IP address, and sends class 1 output data to UDP 2222. A Logix module definition has an IP address but no port number, so there is no way to point the PLC at "the adapter on port 44819".
- RSWho and ListIdentity broadcast to UDP 44818, so only one program per address can answer them.
- Only one program can listen on a given address and port. If two could, the operating system would hand each incoming connection or packet to just one of them, and the PLC would reach Linx when it wanted the adapter.
If a port is taken, Adapter::start fails with an error that names the address and suggests Linx as the likely
holder. Windows reports this as "access denied" when the other program holds the port exclusively.
What still works on the same PC:
- the ciplink client (drivers, browsing, trends) and
ciplink-server. They connect out to PLCs from temporary ports and do not listen on 44818. - Studio 5000 and Linx on another PC, browsing to and going online with the controller that owns the adapter.
If a PC needs both Linx and the adapter, give the adapter its own IP address: a second network card, or a second
address on the plant card. Set bind to that address. Whether Linx leaves that address free depends on how it
binds its sockets (it may take every address), so test it on the target PC before relying on it. Running the
adapter on its own PC or VM avoids the question.
Network notes¶
bindpicks the network card, which is also the card multicast T→O data leaves from. Set it on PCs with more than one card.0.0.0.0listens on all of them, but multicast then leaves from whichever card the OS picks.- The firewall must allow TCP 44818 and UDP 44818 and 2222 inbound, plus multicast to 239.192.x.x if the connection uses multicast T→O.
- The adapter accepts RPIs from 2 ms to 3200 ms by default (
rpi_minandrpi_max).