node-red-contrib-iolink-suite 0.0.1
Node-RED nodes for IO-Link masters: read and write process data decoded through the device's IODD, read and write ISDU parameters by name, and decode raw IO-Link bytes from any source.
node-red-contrib-iolink-suite
Node-RED nodes for IO-Link. Read a sensor's process data as named, scaled, engineering values instead of raw bytes, write actuator outputs by name, and read or write ISDU parameters by their label in the device's IODD.
[ inject ]--->[ iolink read: port 1 ]--->[ debug ]
msg.payload = {
"Temperature": 23.47,
"Counter": 586,
"SwitchingSignal1": true,
"SwitchingSignal2": false
}
The decoding is done by the IODD parser in lib/iodd, which
has no Node-RED dependency — so the iodd decode node works for process data
that never touched an IO-Link master (PROFINET, EtherNet/IP, Modbus, OPC UA,
MQTT, a PLC), and the same decoder is usable from plain Node.js.
Install
From the Node-RED palette manager, or:
cd ~/.node-red
npm install node-red-contrib-iolink-suite
Requires Node.js ≥ 18 and Node-RED ≥ 3.0. Node-RED's Import → Examples → iolink-suite menu has ready-made flows for the common cases.
The nodes
| Node | What it does |
|---|---|
iolink master |
Config node: one per physical master. Holds the connection and one shared IODD cache. |
iolink read |
Reads process data in from one port or several, and decodes it. On a message or on an interval. |
iolink write |
Writes process data output by name, merging into the device's current output. |
iolink param |
Reads and writes ISDU parameters by name, one or several, scaled and with enumerations resolved. |
iolink scan |
Reports which ports are occupied, by which device, and whether an IODD was found. |
iolink event |
Watches port status and device diagnosis, and emits a message only when something changes. |
iodd decode |
Decodes or encodes raw IO-Link bytes against an IODD, with no master involved. |
The six master-bound nodes share one config node, which talks to the master
through a profile and decodes through the IODD library. iodd decode uses the
same library with no master at all:
flowchart LR
subgraph flow["Node-RED flow"]
read["iolink read"]
write["iolink write"]
param["iolink param"]
scan["iolink scan"]
event["iolink event"]
decode["iodd decode"]
end
master["iolink master<br/>config node: connection,<br/>IODD store, identity cache"]
read & write & param & scan & event --> master
subgraph adapters["lib/adapters, raw bytes only"]
ifm["ifm IoT Core"]
jsonapi["IO-Link JSON API"]
generic["Generic HTTP/JSON"]
end
master --> ifm & jsonapi & generic
ifm & jsonapi & generic -- HTTP --> hw["IO-Link master"]
iodd["lib/iodd<br/>IODD parser and codec"]
master -. decodes through .-> iodd
decode -.-> iodd
fieldbus["PROFINET, Modbus, OPC UA nodes"] -- raw bytes --> decode
Configuring a master
Pick a profile in the iolink master config node. Every profile was built
from its interface's documentation and is tested against a stand-in that
speaks it; none has yet been checked against a master on a bench. The
dialog says so next to the choice, and npm run record (below) is how that
changes.
ifm IoT Core — AL13xx / AL19xx / AL2xxx, after ifm's IoT Core documentation: one POST endpoint, the address in the body.
IO-Link JSON API — the IO-Link Community's JSON Integration for IO-Link (spec 10.222, V1.0.0), the one REST interface several vendors share: Balluff and Pepperl+Fuchs ship it on their newer masters, others are following. Everything sits under
/iolink/v1; a gateway holding several masters numbers them, so the dialog asks which. A port renamed in the master's configuration is found by its alias. Values are exchanged as byte arrays and decoded here, through the IODD, like for every other profile.Generic HTTP/JSON — for a master with a REST API of its own shape (Turck's TBEN web API, for one): the request paths are configuration, not code, so a new master is a settings entry. Templates take
{port},{index},{subindex}and{value}, andvaluePathsays where the value sits in the reply (e.g.data.value). The defaults are placeholders, not any vendor's paths.readVendorIdandreadDeviceIdare the two that decide whether IODDs can be found at all: without them the master can say a device is there but not which, andread,writeandparamhave nothing to decode against. They then need the ids pinned on the node itself, and say so (IOLINK_NO_IDENTITY) rather than claiming the port is empty. A path the master refuses is dropped after the first try, so a wrong guess is not repeated on every poll.
Masters that speak only a fieldbus — PROFINET, EtherCAT, EtherNet/IP, Modbus
TCP — have no profile and need none: bring the raw process data in through the
Node-RED node for that fieldbus and hand it to iodd decode.
Give it a host, optionally an HTTP port and HTTPS. A master behind a path or a proxy takes a full base URL instead, which overrides both.
Where the IODD comes from, in order:
- IODD folder (
ioddDir) — files are matched by their content, not their filename, so vendor naming does not matter. A pinned IODD always wins. - IODDfinder, cached on disk (
ioddCacheDir). Tick offline on an air-gapped plant to switch this off.
A device whose IODD is nowhere to be found is remembered as such for retry after (60 s by default), so one unpublished device on a rack does not send a request to IODDfinder on every poll for as long as the flow runs. Dropping the missing IODD into the folder and redeploying clears that at once. Several nodes asking for the same IODD at the same moment share one lookup rather than each starting their own.
flowchart TD
ids["vendorId and deviceId<br/>from the port, or pinned on the node"] --> mem{"parsed in memory?"}
mem -- yes --> use["decode"]
mem -- no --> failed{"not found less than<br/>retry after ago?"}
failed -- yes --> err["error, no request sent"]
failed -- no --> folder{"IODD folder holds it?"}
folder -- yes --> use
folder -- no --> cache{"disk cache holds it?"}
cache -- yes --> use
cache -- no --> offline{"offline?"}
offline -- yes --> remember["remembered as missing<br/>until retry after"] --> err
offline -- no --> finder["download from IODDfinder"] --> found{"found?"}
found -- yes --> use
found -- no --> remember
Checking a profile against a real master
npm run record -- ifm http://192.168.1.50/ --ports 1,2
npm run record -- jsonapi http://192.168.1.60/ --user admin --password secret
sends every read the profile relies on — nothing is written — and stores the
raw exchanges, request and reply, under recordings/. A reply that differs
from what the stand-in in test/ answers is a difference between the
documentation and the device, and the file is what an issue or a fixture
needs.
Reading process data
Point the node at a master and a port, then tick the values you want. The editor asks the master what is on the port and lists the values by name, with units — you never type a bit offset.
- Output: single — one message,
msg.payloadan object of every value. - Output: split — one message per value, with
msg.topicset to<prefix>/<value>, which maps straight onto MQTT topics.
Alongside payload, each message carries meta (unit, range, type, bit offset
per value), device (vendor, product, serial, port) and iolink (the raw hex,
the layout id, the octet count).
Port takes one port, or a list of them — 1,3 typed as a string, or an
array from a message. Several ports come back as one message keyed by port,
with every part of the single-port message keeping its name underneath:
msg.payload[1].Temperature several ports
msg.payload.Temperature one port, unchanged
meta, device and iolink are keyed the same way. The ports are read one
after another, not at once — a master is a small device on the end of a wire.
A port that does not answer is left out and named under msg.errors; only when
no port at all answers does the read fail. In split mode the port joins the
topic (<prefix>/port3/<value>), because two devices of one type carry the
same value names.
Set an interval to poll: the node reads once as soon as the flow is deployed and then on every tick. A tick is skipped while the previous read is still in flight, so a slow master cannot build a backlog.
Which device is on the port
read, write and param ask the port who is on it and cache the answer for
re-check (30 s by default) — asking before every read would triple the
traffic to the master, and pinning it for ever would survive a device being
swapped. Shorten it where devices are changed while the flow runs. The cache
belongs to the master, not to the node: three nodes on one port ask it once,
and the node with the shortest re-check refreshes it for the others. A master
that does not answer is not cached, so an outage ends when the master returns
rather than one re-check later.
One read, from the message to the decoded values:
sequenceDiagram
participant F as flow
participant R as iolink read
participant C as identity cache
participant M as IO-Link master
participant S as IODD store
F->>R: msg, port 1
R->>C: who is on port 1?
alt answer younger than re-check
C-->>R: vendorId, deviceId, product, serial
else expired, or a shorter re-check asked
C->>M: port status, identification
M-->>C: vendorId 999, deviceId 4242
C-->>R: kept for the next re-check
end
R->>S: IODD for 999 / 4242
S-->>R: parsed device
R->>M: process data in, port 1
M-->>R: 092B0929
R->>R: decode through the IODD
R-->>F: payload, meta, device, iolink
The device block on every message names the product the master reports,
falling back to the IODD's first variant only when the master does not say — a
family IODD covers several products, and the first listed is not necessarily
the one on the port.
Filling in vendor ID and device ID pins the device instead: the node loads that IODD and stops asking. That is the answer for a master whose API cannot report an identity, and a way to hold a port to the device type it is meant to carry.
Writing process data
Send msg.payload = { "Valve": true }. By default the node reads the device's
current output first and merges into it, so setting one field leaves the others
alone — without that, writing one valve on a manifold would close every other
one. Turn merge off if the master cannot read its output back, and then
supply every value.
Values are range-checked against the IODD before anything is written.
Reading and writing parameters
iolink param addresses an ISDU by its name (or index), and applies the IODD's
scaling and enumerations in both directions:
msg = { action: "write", payload: 30 } → Switch point, index 100, hex 0BB8
msg = { action: "read" } → msg.payload = 23.47, msg.meta.unit = "°C"
Read-only and write-only parameters are refused with a clear message rather
than by the device, and a record can be addressed per msg.subindex.
Diagnosis
iolink event polls the ports and emits a message only when something changes:
a device appearing or disappearing, a port leaving operate, a wire break.
A port as the node sees it, with event.direction on the transitions. A master
that stops answering is its own state rather than every device leaving, and
the last known state is carried through it:
stateDiagram-v2
direction LR
empty: port empty
connected: device connected
down: master unreachable, last state kept
[*] --> empty: baseline, silent
[*] --> connected: baseline, silent
empty --> connected: appeared
connected --> empty: disappeared
connected --> connected: device changed, status changed
empty --> down: unreachable
connected --> down: unreachable
down --> connected: reachable
down --> empty: reachable, or disappeared
Tick device status and it additionally reads the two objects every IO-Link
device must provide — DeviceStatus (ISDU 36) and DetailedDeviceStatus
(ISDU 37) — so the message also carries whether the device says it is OK,
needs maintenance, is out of specification or has failed, together with the
EventCodes standing behind that, resolved to their meaning:
{ "deviceStatus": 1, "deviceStatusText": "Maintenance required",
"deviceEvents": [ { "hex": "0x8C40", "name": "Maintenance required - Cleaning",
"type": "Warning", "mode": "appears" } ] }
Those two objects are specified for every device, not vendor extensions, so this works on any master that can read an ISDU.
Errors
Every node reports failures the same way: the node goes red with a short status, and the error reaching a Catch node is prefixed with a code you can branch on.
| Code | Meaning |
|---|---|
IOLINK_MASTER_UNREACHABLE |
The master did not answer at all, so nothing is known about the port. Not cached: the first read after the master returns succeeds. |
IOLINK_NO_DEVICE |
The master answered, and the port reports no IO-Link device. |
IOLINK_NO_IDENTITY |
A device is there, but the master will not say which — pin the ids, or configure the identity paths. |
IOLINK_NO_DATA |
The master returned no value. |
IOLINK_BAD_PORT |
The port number could not be resolved from the message. |
IOLINK_BAD_SUBINDEX |
The subindex is not a whole number from 0 to 255. |
IOLINK_BAD_HEX |
A value the master returned, or a record to be written, is not proper hex. |
IOLINK_BAD_PAYLOAD |
The payload was not the shape the node needs. |
IOLINK_OUT_OF_RANGE |
The value is outside the range the IODD declares. |
IOLINK_READ_ONLY / IOLINK_WRITE_ONLY |
The parameter does not allow it. |
IOLINK_UNKNOWN_PARAMETER / IOLINK_UNKNOWN_SUBINDEX |
No such parameter in the IODD. |
IOLINK_NO_PARAMETER |
No parameter was selected, in the node or on the message. |
IOLINK_UNSUPPORTED_PARAMETER |
The IODD describes the parameter in a way this decoder cannot handle. |
IOLINK_MERGE_UNAVAILABLE |
The master cannot read its output back to merge into. |
IODD_* |
Raised by the decoder: a bad IODD, an ambiguous variant, wrong data length. |
Layout
Everything lives in one package, in one place:
nodes/ the seven nodes, runtime .js beside editor .html
nodes/icons/ the palette icon, black on transparent, 2:3 as Node-RED wants
(the registry only finds icons beside the node files)
lib/ adapters/ one file per master profile, raw hex only
iodd/ the IODD parser and the process data codec
test/ unit tests; test/iodd/ covers the decoder
scripts/ the editor linter, the IODD corpus fetcher, the unit generator
docker/ the containers the test suites run in
examples/ flows offered in Node-RED's Import → Examples menu
Releasing
A version tag is a release. Move the changelog's Unreleased section under the new version, then:
npm version minor # bumps package.json, commits, tags v0.2.0
git push --follow-tags
The publish workflow checks that the tag matches package.json, runs lint,
the unit suite and the Node-RED integration test on the tagged commit,
publishes to npm, and creates a GitHub release with that version's changelog
section as its notes.
It publishes through npm's trusted publishing: there is no token in the repository, the workflow identifies itself with a GitHub OIDC token, and npm attaches provenance naming the commit and the run. Two things have to be in place once:
The package has to exist on npm, because a trusted publisher is configured on a package's settings page. npm cannot yet do a first publish this way, so the very first version goes up by hand, as a placeholder below the first real release:
docker run -it --rm -v "$PWD":/app -w /app node:22 sh -c \ "npm login && npm pkg set version=0.0.0 && npm publish --access public" git checkout package.json package-lock.json # the 0.0.0 stays on npm onlyA trusted publisher on npmjs.com: package settings, Trusted Publisher, GitHub Actions, with owner
blanpa, repositorynode-red-contrib-iolink-suiteand workflow filenamepublish.yml. No environment.
Provenance is only issued for public repositories, so the repository has to be public before the first tag.
The simulator
test/fake-master.js is a stand-in for an ifm IoT Core master: the same request
and reply envelope, the same quirks (uppercase hex, a code in the body that is
not the HTTP status). The test suite runs against it, and so can you, for
building a flow or demonstrating one without a master on the desk.
npm run simulator # a plant on :8080, values that move
node test/fake-master.js # the fixed rack the tests use
node test/fake-jsonapi-master.js # the same rack behind the JSON API, on :8081
# Node-RED with the suite installed, wired to the simulator, editor on :1880
SIM_PLANT=test/fixtures/simulator-plant.json \
docker compose -f docker-compose.test.yml up node-red
A plant file describes the rack: which IODD sits on which port, and what its values are doing. The process data is encoded through that IODD, so the bytes on the wire are the bytes the device description says they should be, scaling included — not a hex string somebody typed.
"1": {
"iodd": "demo-sensor.iodd.xml",
"values": {
"Temperature": { "wave": "sine", "min": 18.5, "max": 24.5, "periodMs": 60000 },
"Counter": { "wave": "ramp", "min": 0, "max": 16000, "periodMs": 120000 },
"SwitchingSignal1": { "wave": "square", "periodMs": 8000 }
},
"deviceStatus": 1,
"events": ["0x8C40"]
}
Waves: sine, ramp, triangle, square, random, constant — all
functions of the clock alone, so a run can be replayed exactly. A bare number,
string or boolean is a fixed value.
While it runs, the rack can be changed from outside — which is what makes an alarm flow demonstrable:
curl localhost:8080/sim # the whole rack
curl -d '{"connected":false}' localhost:8080/sim/port/1 # pull a device
curl -d '{"deviceStatus":2,"events":["0x8C10"]}' localhost:8080/sim/port/1
curl -d '{"values":{"Temperature":42.5}}' localhost:8080/sim/port/1
The control API lives beside the master's endpoint, never inside it: no message
a flow sends can reach it as a flow message. In the Docker environment the
simulator is not published to the host — the test harness must not fight
whatever else holds a port — but it is reachable inside the compose network at
http://fake-master:8080/sim, which an http request node can drive: an
inject node that pulls a device out is a fine way to demonstrate an alarm.
Testing
npm test # unit tests, no hardware and no network
npm run lint # including the editor scripts inside the .html
npm run test:docker # the same suite in a clean container
npm run test:integration # the nodes loaded into real Node-RED, in Docker
npm run test:all # both of the above
The suite runs against a fake ifm IoT Core master (test/fake-master.js) that
speaks the real request and reply envelope, so the adapters, the nodes and a
whole flow are exercised end to end without a master on the desk. The Docker
integration test installs the packed tarball into the official
nodered/node-red image — so anything left out of package.json fails there
and not on a user's machine — deploys a flow that uses every node, and checks
the decoded values that come out the other end.
The editor halves of the nodes are linted too: standard reads .js files, so
scripts/lint-editors.js lifts each <script> block out of the .html, lints
it, and maps the line numbers back. A unit test also checks that every property
in a node's defaults has somewhere in its edit dialog to set it — a mismatch
there is invisible at runtime and leaves a setting reachable only by editing the
flow file by hand.
Real vendor IODDs are copyrighted and are never committed. npm run corpus
fetches a set from IODDfinder; the decoder's corpus tests skip without it.
Adding a master profile
One file in lib/adapters/, one entry in lib/adapters/index.js. Adapters
deal in raw hex only — decoding stays in lib/iodd — so a new vendor never
touches the decoding path. Profiles verified against real hardware are marked
as such in the registry; please say which you tested against.
Sponsor this project
This package is developed and maintained in my own time. If it saves you some, consider supporting it:
Licence
Apache-2.0