ina2000
v2.0.0
Published
Client for the B&R INA2000 PLC protocol over Ethernet (UDP) or serial (RS232/RS485): browse tasks and variables, read, write, subscribe, decode the logbook and discover PLCs
Maintainers
Readme
ina2000
Node.js client for the B&R INA2000 protocol, the service every B&R PLC (and the ARsim simulator of Automation Studio) offers for listing tasks and reading and writing process variables. Works over Ethernet (UDP) or serial (RS232). Browse tasks and variables, read and write values, subscribe to changes, read and set the PLC clock, decode the logbook, run service functions, and discover PLCs. Promise-based API, TypeScript declarations, no required dependencies, a test suite with a fake PLC, a Wireshark dissector and a serial decoder.
Background
IoT solutions are increasingly relevant in the automation world, and together with IoT comes JavaScript. This module was written to read and write data of B&R PLCs from Node.js. INA2000 (Industrial Network Architecture) is a protocol B&R has used since the 1990s; it targets the old generation of PLCs but also works with the current ones, and the service is active by default. Newer B&R PLCs additionally offer ANSL (Automation Network Service Line), which is TCP based, whereas INA2000 is UDP based.
The protocol was reverse engineered from PVI traffic.
B&R specific payloads used here comes from observation and is described in the
comments of lib/.
Requirements
Node.js 20 or newer. Windows, Linux and macOS.
Install
npm i ina2000Quick start
const { Ina2000Client } = require('ina2000');
async function main() {
const plc = new Ina2000Client({ host: '192.168.250.10', port: 11159 });
await plc.connect();
// optional: what is running on the PLC?
const { tasks } = await plc.browse();
for (const task of tasks) console.log(task.name, task.variables.map((v) => `${v.name}:${v.type.name}`));
// read and write by name ("Task:variable" or "globalVariable")
const [counter, label] = await plc.read(['Program:counter', 'Program:label']);
await plc.write('Program:counter', counter + 1);
await plc.writeMany({ 'Program:label': 'hello', 'Program:enabled': true });
// cyclic reading with change notification
const subscription = await plc.subscribe(['Program:counter', 'gStatus'], { interval: 500 });
subscription.on('change', (variable, value, previous) => {
console.log(`${variable.name}: ${previous} -> ${value}`);
});
await new Promise((resolve) => setTimeout(resolve, 10000));
subscription.stop();
await plc.disconnect();
}
main().catch(console.error);The package is CommonJS; import { Ina2000Client } from 'ina2000' works in
ES modules too. Ports: real PLCs listen on 11159, ARsim on 11160.
Connecting: Ethernet or serial
The same client works over Ethernet (UDP) or serial. Only the constructor options differ; every method below behaves identically once connected.
Ethernet (default). Pass host, and optionally port and timing options:
const { Ina2000Client } = require('ina2000');
const plc = new Ina2000Client({
host: '192.168.137.50',
port: 11159, // 11159 on real PLCs, 11160 on ARsim; default 11159
timeout: 1000, // ms to wait for a reply before resending
retries: 3, // resends before a request fails
});
await plc.connect();Serial (RS232). Pass serial instead of host. This needs the
optional serialport package, so install it once:
npm i serialportconst plc = new Ina2000Client({
serial: {
path: 'COM1', // or '/dev/ttyUSB0'
baudRate: 57600,
parity: 'even', // 'even' | 'odd' | 'none'
dataBits: 8,
stopBits: 1,
},
});
await plc.connect();
// browse / read / write / subscribe exactly as over Ethernet
console.log(plc.medium); // 'serial'Match the serial parameters to the PLC's interface (Automation Studio, Online
Settings). INA2000 over serial is the same protocol; only the transport differs,
the FDL telegram without the 6-byte UDP header. The web console
(npm run webui) has an Ethernet/Serial selector for the same reason.
API
new Ina2000Client(options)
| option | default | meaning |
| --- | --- | --- |
| host | required for Ethernet | PLC address (omit when using serial) |
| port | 11159 | INA2000 UDP port |
| serial | none | { path, baudRate, parity, dataBits, stopBits } to connect over serial instead of Ethernet (needs serialport) |
| transport | none | inject a custom transport (advanced) |
| timeout | 1000 | milliseconds to wait for a reply before resending |
| retries | 3 | resends of an unanswered telegram before the request fails |
| keepAliveInterval | 1000 | interval of the POLL telegram that keeps the connection open |
| stringEncoding | 'latin1' | encoding of STRING values |
| localNode | 69 | node number written into the transport header |
| logger | silent | object with debug, info, warn functions |
Properties: state (disconnected, connecting, connected,
disconnecting), connected, connectionId, tasks (result of the last
browse()), variables (registered variables), stats (telegram counters).
Methods
All methods return promises. Requests are sent one at a time (the protocol is stop-and-wait), so calls may be issued concurrently and are queued.
connect()establishes the connection and starts the keep-alive.disconnect()stops subscriptions, sends the abort telegram and closes the socket.browse()returns{ modules, tasks }. Every module hasid,name,type,status,address,size,version,timestamp; task modules (type 17) additionally list theirvariablesas{ name, datatype, type: { name, typeid } }. Type names include arrays (INT[0..9]), strings (STRING[80]) and user types (MyStruct).register(names)defines the variables on the PLC and returns their descriptors{ name, id, datatype, type, length }. Names already registered on this connection are reused.read,writeandsubscriberegister on demand, so callingregisteryourself is optional.read(names)returns the values in the same order.readValue(name)returns one value.write(name, value)andwriteMany(entries)write values;entriesmay be an object{ name: value }, aMap, or an array of[name, value]pairs. The promise resolves once the PLC acknowledged the telegram.subscribe(names, { interval })registers the variables and reads them everyintervalmilliseconds (default 500). Returns aSubscription.resolveAddress(name)returns a task-local variable's absolute RAM address from browse data (task data base plus the variable's offset), over pure INA. Supports array elements (Task:arr[5]); throws for struct members and PV/global variables.readMemory(address, length)reads raw PLC memory (used bybrowse).
Events
connect({ connectionId })disconnect({ reason }):reasonisnullafterdisconnect()and anErrorwhen the PLC stopped answering keep-alive telegrams. The client is back in thedisconnectedstate; callconnect()again and re-subscribe.error(err): emitted for a lost connection only when a listener exists.
Subscription
variables,interval,values(aMapof the last values),activestop()- event
data(values: Map)after every successful cycle - event
change(variable, value, previous)for every changed value; the first cycle reports every variable withprevious === undefined - event
error(err)when a cycle failed (logged when nobody listens)
discover(options)
Broadcasts identify datagrams and resolves with the PLCs that answered:
[{ address, port, node, raw }].
const { discover } = require('ina2000');
const found = await discover({ broadcast: '192.168.250.255', port: 11159, from: 1, to: 100 });Options: broadcast (default 255.255.255.255), port, from/to node
numbers (1..100), timeout (ms to wait after the last datagram, 1000),
stagger (ms between datagrams, 10), bindAddress.
connect(options)
Shortcut that creates a client and connects it.
Errors
TimeoutError (no reply after the retries), ConnectionError (not
connected), VariableError (a variable was not found; names lists them),
ProtocolError (unexpected reply; frame holds the raw datagram).
Values and data types
| PLC type | JavaScript | notes |
| --- | --- | --- |
| BOOL | boolean | any non-zero byte reads as true |
| SINT, INT, DINT, USINT, UINT, UDINT | number | range checked on write |
| REAL | number | IEEE 754 single precision |
| STRING | string | ends at the first NUL; truncated to the declared length on write |
| arrays of the above (USINT[0..99]) | Array | whole array; missing elements are written as 0 |
| other (structures, LREAL, TIME, ...) | Buffer | raw bytes of the registered length |
Variable names use Task:variable for task local variables and variable
for global ones. Single array elements work (Program:values[3]). Whole
structures cannot be registered through the variable-list service; read and
write them by name with readStruct / writeStruct after a browse(), which
resolve the address and go through memory instead.
Structures
Define a structure's layout once, then validate, pack and unpack values against
it. defineStruct fills in member offsets by natural alignment (or use explicit
offset values you have verified against the PLC).
const { defineStruct, validate } = require('ina2000');
const settings = defineStruct({
name: 'SettingsType',
members: [
{ name: 'setpoint', type: 'INT' },
{ name: 'enabled', type: 'BOOL' },
{ name: 'label', type: 'STRING', size: 21 }, // bytes including NUL
{ name: 'limits', type: 'INT', count: 2 }, // INT[0..1]
],
});
validate(settings, { setpoint: 70000 });
// throws ValidationError: setpoint: 70000 is out of range for INT [-32768..32767]Every write is validated before any bytes are sent, with one issue per bad
member and a dotted path (sub.limits[1]). Nested structures and arrays of
structures are supported.
You often do not need to write a schema at all. After a browse(), the client
reads each task's type table, so it knows a structure's members (name, type and
offset) and builds the schema itself. Reading or subscribing to a struct by name
returns a decoded object with named members, and writeStruct with a null
schema builds one from browse data:
await plc.browse();
const s = await plc.readValue('IMA_Serv:struct_data'); // { a: 123, b: 456 }
await plc.writeStruct('IMA_Serv:struct_data', null, { a: 7, b: 8 });
plc.structSchema('IMA_Serv:struct_data'); // the built schema, or nullstructSchema returns null when a member type is not a decodable base type (a
nested struct, for example); pass your own schema for those. You can still supply
a schema explicitly, which also sets the byte order (defaults to little-endian,
the native order):
const value = await plc.readStruct('Program:settings', settings);
await plc.writeStruct('Program:settings', settings, { setpoint: 1500, enabled: true, label: 'run', limits: [0, 100] });Resolving names to addresses
After a browse(), the client can turn a task-local variable name into its
absolute PLC RAM address over pure INA, the way Automation Studio does: the
owning task's data-segment base (from the module list) plus the variable's
offset (from the variable table). No ANSL and no address from you.
await plc.browse();
plc.resolveAddress('IMA_Serv:struct_data'); // e.g. 0x04824db4
plc.resolveAddress('Program:hugeVariable[3]'); // array element addressThis works for task-local variables and array elements. Struct members
(Task:s.field) and PV/global variables (resolved by the PLC's own lookup
service) are not resolvable this way yet and throw a VariableError.
Large arrays and structs
A large array such as INT[0..999] is bigger than one read reply. After a
browse(), read and subscribe resolve its address and fetch it in one
chunked memory read, returning a JavaScript array. Without browse data they fall
back to reading it element by element (slower, but no address needed).
const all = await plc.readValue('Program:hugeVariable'); // 1000 numbersWriting a whole large array by name is not supported (set elements
individually, Program:hugeVariable[3], or use writeMemory).
Structs read and write by name too, once you have browsed. readStruct and
writeStruct resolve the address over INA and read or write the structure in
memory; you only supply the schema:
await plc.browse();
const value = await plc.readStruct('Program:settings', settings);
await plc.writeStruct('Program:settings', settings, { setpoint: 1500, enabled: true, label: 'run', limits: [0, 100] });If a name cannot be resolved (before browse(), or a PV/global variable), these
fall back to the variable-list service, which only works for the rare
registerable struct. You can always read or write at an explicit address with
readStructAt / writeStructAt, or raw bytes with readMemory / writeMemory:
const value = await plc.readStructAt(0x04824db4, settings);
await plc.writeStructAt(0x04824db4, settings, { setpoint: 1500, enabled: true, label: 'run', limits: [0, 100] });
const buf = await plc.readMemory(0x04824db4, 2000); // e.g. a 2000-byte array
await plc.writeMemory(0x04824db4, buf);Loggers and modules
Read a B&R logger module and decode its binary logbook into records, over pure INA using the FMS upload sequence. The module must exist in the module list, which is checked first.
const entries = await plc.readLogbook('System'); // $arlogsys
for (const e of entries) {
console.log(e.time.toISOString(), e.severity, e.area, e.code, e.message);
}
// { recordId, severity: 'Info'|'Warning'|'Error'|'Success', time: Date, code, area, message }
const userLog = await plc.readLogbook('User'); // $arlogusr
const named = await plc.readLogbook('myUserLogger'); // a user logger by name
const raw = await plc.readLog('System'); // undecoded bytes as textThe human readable description a tool shows beside each entry is a code lookup
from B&R's error database and is not stored in the log, so readLogbook returns
the record's own message text and code, not that description.
Logger types map the reserved names: System $arlogsys, User $arlogusr,
Fieldbus $fieldbus, Safety $safety, Redundancy $redund,
Connectivity $arlogconn, TextSystem $textsys, UnitSystem $unitsys,
AccessAndSecurity $accsec, Visualization $visu. Not every logger exists
on every PLC, so readLog throws if the module is not in the module list.
uploadModule(name) returns the raw bytes of any uploadable module, and
getStatus() returns { version, cpu, raw } from the status report.
Limits
INA2000 telegrams are at most 255 bytes. The client splits requests
accordingly: at most 29 variables and 220 value bytes per read telegram,
14 names per registration telegram, and memory reads in chunks of 226 bytes.
A whole array larger than one reply (for example an INT[0..999]) or a struct
is read from memory at its resolved address after a browse(), or, without
browse data, an array is read element by element and returned as a JavaScript
array. The variable service offers no partial reads. The variable tables read
by browse() do carry each task-local variable's segment offset, so the client
resolves names to addresses over INA (see resolveAddress); PV/global variables
are resolved by a separate PLC lookup service that is not implemented yet.
readMemory() is meant for the task module images that browse() reads.
Asking ARsim for an address outside its memory map produced no reply at all
(the request times out and the connection is closed), so do not probe
addresses blindly.
Upgrading from 1.x
The 1.x API is available unchanged from the legacy entry point:
const ina2000 = require('ina2000/legacy');
ina2000.connect('127.0.0.10', 11160).on('connected', (e) => {
ina2000.registerVariable(['gVar', 'Program:localVar'], 1000).on('valueChanged', (variable, value) => {
console.log(variable.name, value);
});
});| 1.x | 2.x |
| --- | --- |
| connect(ip, port).on('connected') | await plc.connect(); await plc.browse() |
| registerVariable(names, cycle).on('valueChanged') | (await plc.subscribe(names, { interval })).on('change') |
| registerDone event | subscription.variables |
| writeValue(variable, value) | await plc.write(name, value) |
| searchPLCs(broadcast, port, from, to).on('plcSearchFinished') | await discover({ broadcast, port, from, to }) |
| disconnect() | await plc.disconnect() |
See CHANGELOG.md for the behaviour changes and the bugs fixed along the way.
Wireshark
tools/wireshark/ina2000.lua dissects INA2000 telegrams (FDL frame, LLI,
FMS PDU and the B&R reports). Copy it into Wireshark's personal plugins
folder or start with -X lua_script:tools/wireshark/ina2000.lua;
tools/wireshark/README.md lists useful display filters, and
sample-session.pcapng is a recorded session against ARsim.
Set NODE_DEBUG=ina2000 to print every telegram the client sends and
receives as hex.
Protocol notes
A datagram is a 6 byte transport header (01, node, FDL length, 00 00)
followed by a Profibus style FDL telegram: 68 LE LE 68, DA, SA, FC, DSAP,
SSAP, DATA, FCS (sum of DA..DATA mod 256), 16. DATA holds an LLI-FC byte
and an FMS PDU (ID-Info 1, invoke id, ID-Info 2, service data). Variable and
module operations are B&R specific "informationReport" PDUs on object index
fe 03. The frame control byte carries the frame count bit: it toggles with
every new request, stays the same on a retry, and a PLC that receives the same
bit twice repeats its previous reply instead of executing the request again.
lib/constants.js and lib/requests.js document the byte layouts.
Development
npm test # unit tests plus a fake PLC over UDP
node examples/browse.js 127.0.0.10 11160 # against ARsim
node examples/write.js 127.0.0.10 11160 Program:writeMe 42
node examples/subscribe.js 127.0.0.10 11160 Program:writeMe gVar
node examples/discover.js 192.168.250.255 11159 1 100The original implementation is kept in legacy/index.js for reference;
test/requests.test.js proves that the new telegram builders produce the
same bytes.
Test environment
A runnable simulator can be downloaded from
Google Drive:
unzip it and run ar000loader; an icon appears in the tray. The development
environment is available from the
B&R download page
and is only needed to change what runs in the simulator.
Contact
Issues, questions and feature requests: [email protected]. A short note if you end up using this package, even just for testing, is appreciated.
License
ISC
