PLC Relay is an edge container service that provides an HTTP API for reading and writing PLC tags. You configure it through the Deployment Manager UI by selecting a protocol, entering connection details, and defining tags.
PLC Relay is available exclusively for Enterprise customers. Contact the Roboflow sales team to learn more.
Supported Protocols
When adding or editing a PLC Relay service, you select one of three protocols. Each protocol has its own connection settings and tag format.
| Protocol | PLC_DRIVER | PLCs | Default Port |
|---|---|---|---|
| Allen-Bradley (EtherNet/IP) | allen_bradley | CompactLogix, ControlLogix, Micro800 | 44818 |
| Modbus TCP | modbus | Any Modbus TCP device | 502 |
| Siemens S7 | siemens_s7 | S7-300, S7-400, S7-1200, S7-1500 | 102 |
Switching protocols clears all configured tags because tag address formats are not interchangeable between protocols. The UI will prompt for confirmation before applying the change.
Connection Settings
PLC Address
The address format depends on the selected protocol:
- Allen-Bradley: IP or hostname, optionally followed by
/slot(e.g.192.168.1.100/0) or a full CIP routing path. - Modbus TCP: IP or hostname, with optional
:port(e.g.192.168.1.100:502). Also requires a Unit ID (0-255) and Word Order (big or little) for 32-bit values. - Siemens S7: IP or hostname, with optional
:port(e.g.192.168.1.100:102). Also requires Rack (0-7) and Slot (0-31).
Simulation Mode
When enabled, PLC Relay uses an in-memory simulator instead of connecting to a real PLC. All API operations work normally, but values are stored in memory. This is useful for testing without hardware.
Tag Configuration
Tags define the PLC data points accessible through the API. Each tag has a name, data type, writable flag, and optional description.
Data Types
| Type | Description | Range |
|---|---|---|
BOOL | Boolean | true / false |
INT | 16-bit signed integer | -32,768 to 32,767 |
DINT | 32-bit signed integer | -2,147,483,648 to 2,147,483,647 |
REAL | 32-bit floating point | IEEE 754 |
Tag Name Formats
Tag names match the PLC program and are case-sensitive.
| Style | Example |
|---|---|
| Simple | TagName |
| Program-scoped | Program:MainProgram.TagName |
| Array element | TagName[0] |
| UDT member | MyUDT.Member |
Format: {area}:{address} where address is a non-negative integer.
| Area | Type(s) | Access | Example |
|---|---|---|---|
coil | BOOL | Writable | coil:0 |
discrete | BOOL | Read-only | discrete:5 |
holding | INT, DINT, REAL | Writable | holding:100 |
input | INT, DINT, REAL | Read-only | input:200 |
Data Block format: DB{n}.DB[XWD]{byte}[.{bit}]
Area format: [MIQEA][WD]?{byte}[.{bit}]
| Address | Type | Description |
|---|---|---|
DB1.DBX0.0 | BOOL | Bit 0 of byte 0 in Data Block 1 |
DB1.DBW0 | INT | 16-bit word in DB1 |
DB1.DBD0 | DINT or REAL | 32-bit double-word in DB1 |
M0.0 | BOOL | Merker bit |
I0.0 / Q0.0 | BOOL | Process input/output bit |
MW0 / MD0 | INT / DINT or REAL | Merker word / double-word |
For S7-1200/1500: enable PUT/GET in TIA Portal and disable optimized block access on accessed DBs.
Web Dashboard
PLC Relay includes a built-in web dashboard for monitoring tag values in real time. Once the service is running, access it at http://<device-ip>:8007.
The dashboard also hosts interactive Swagger documentation at /docs and a visual config builder at /static/config-builder.html.
HTTP API
The API reads and writes the configured tags over HTTP, so a pipeline can exchange PLC data without speaking Allen-Bradley EtherNet/IP, Modbus TCP, or Siemens S7 directly. Base URL is http://<device-ip>:8007, and the API is unauthenticated. See Services for the rules shared by all on-device service APIs.
Tags come from the PLC_TAGS environment variable on the service and cannot be created or changed through the API. Only values can be written, and only for tags configured as writable.
Read Before You Trust the Status Code
A request that reaches the service but fails at the PLC returns 200. Check the body:
/readreturnsvalue: nullwitherrorpopulated./writereturnssuccess: falsewitherrorpopulated./healthzreturnsplc_connected: false.
Genuine 4xx responses mean the request itself was wrong: 404 for a tag that is not configured, 403 for a tag configured as read-only, 400 for an empty batch or a duplicate tag name in a write batch, 422 for a body or query parameter that fails validation.
Health and Validation
/healthz also reports the active driver, whether the relay is in live or simulation mode, and the latest tag validation summary. Validation compares each configured tag against the PLC and reports it as ok, not_found, type_mismatch, or not_validated.
Health check
Returns service status, PLC connection state, the active driver and mode, and the latest tag validation summary. Returns 200 with plc_connected: false when the PLC is unreachable, so check the field rather than the status code.
200Service statusapplication/json
Service status, healthy or unhealthy
Whether the PLC is connected
Number of configured tags
Active PLC driver
allen_bradleymodbussiemens_s7Whether the relay is talking to a real PLC or the in-memory simulator
livesimulationConfigured PLC address
Latest validation results, null when validation has not run
Show propertiesHide properties
Whether validation has been performed
Total number of configured tags
Tags that passed validation
Tags not found on the PLC
Tags whose PLC type does not match the configuration
Tags not yet validated
Per-tag validation details, keyed by tag name
GET /healthz HTTP/1.1
Host: device-ip:8007
Accept: application/jsoncurl -L \
--request GET \
--url 'http://device-ip:8007/healthz' \
--header 'Accept: application/json'const response = await fetch("http://device-ip:8007/healthz", {
method: "GET",
headers: {
"Accept": "application/json"
}
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/healthz"
headers = {
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
print(response.json()){
"status": "healthy",
"plc_connected": true,
"tag_count": 3,
"plc_driver": "allen_bradley",
"mode": "live",
"plc_ip": "192.168.1.100/0",
"tag_validation": {
"validated": true,
"total": 3,
"ok": 3,
"not_found": 0,
"type_mismatch": 0,
"not_validated": 0,
"tags": {}
}
}Re-run validation after a PLC program change or a reconnection:
Re-run tag validation
Validates every configured tag against the PLC, checking that it exists and that its data type matches the configuration. Useful after a PLC program change or a reconnection. Returns a summary with validated: false and zeroed counts when no validation results are available.
200Validation summaryapplication/json
Whether validation has been performed
Total number of configured tags
Tags that passed validation
Tags not found on the PLC
Tags whose PLC type does not match the configuration
Tags not yet validated
Per-tag validation details, keyed by tag name
503Service not initializedapplication/json
Error message
POST /validate HTTP/1.1
Host: device-ip:8007
Accept: application/jsoncurl -L \
--request POST \
--url 'http://device-ip:8007/validate' \
--header 'Accept: application/json'const response = await fetch("http://device-ip:8007/validate", {
method: "POST",
headers: {
"Accept": "application/json"
}
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/validate"
headers = {
"Accept": "application/json"
}
response = requests.post(url, headers=headers)
print(response.json()){
"validated": true,
"total": 1,
"ok": 1,
"not_found": 1,
"type_mismatch": 1,
"not_validated": 1,
"tags": {
"ANY_ADDITIONAL_PROPERTY": "anything"
}
}{
"detail": "text"
}Tag Definitions
Tag names are PLC addresses in the format for the active driver, described in Tag Name Formats.
Get tag definitions
Returns the tag schema as defined by the PLC_TAGS environment variable.
200Tag definitionsapplication/json
Show propertiesHide properties
PLC tag address. The format is driver-specific: Allen-Bradley "Program:MainProgram.Tag1", Modbus "holding:100" or "coil:0", Siemens S7 "DB1.DBD0" or "M0.0".
Tag data type. INT is a 16-bit signed integer (-32,768 to 32,767), DINT a 32-bit signed integer (-2,147,483,648 to 2,147,483,647), and REAL a 32-bit float.
BOOLINTDINTREALHuman-readable description
Whether the tag can be written to
trueNumber of tags
GET /schema HTTP/1.1
Host: device-ip:8007
Accept: application/jsoncurl -L \
--request GET \
--url 'http://device-ip:8007/schema' \
--header 'Accept: application/json'const response = await fetch("http://device-ip:8007/schema", {
method: "GET",
headers: {
"Accept": "application/json"
}
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/schema"
headers = {
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
print(response.json()){
"tags": [
{
"name": "text",
"type": "BOOL",
"description": "",
"writable": true
}
],
"count": 1
}Read and Write Values
curl "http://<device-ip>:8007/read?tag=Station1.CycleCount"
curl -X POST http://<device-ip>:8007/write \
-H "Content-Type: application/json" \
-d '{"name": "Station1.CycleCount", "value": 42}'Read all tag values
Returns the current value of every configured tag, along with connection state.
200Current values for all tagsapplication/json
Show propertiesHide properties
Tag data type. INT is a 16-bit signed integer (-32,768 to 32,767), DINT a 32-bit signed integer (-2,147,483,648 to 2,147,483,647), and REAL a 32-bit float.
BOOLINTDINTREALCurrent value, null when the read failed
Show propertiesHide properties
Read error, null on success
oknot_foundtype_mismatchnot_validated503Service not initializedapplication/json
Error message
GET /all_tags HTTP/1.1
Host: device-ip:8007
Accept: application/jsoncurl -L \
--request GET \
--url 'http://device-ip:8007/all_tags' \
--header 'Accept: application/json'const response = await fetch("http://device-ip:8007/all_tags", {
method: "GET",
headers: {
"Accept": "application/json"
}
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/all_tags"
headers = {
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
print(response.json()){
"tags": [
{
"name": "text",
"type": "BOOL",
"description": "text",
"writable": true,
"value": true,
"last_updated": "text",
"error": "text",
"validation_status": "ok",
"validation_detail": "text"
}
],
"count": 1,
"plc_connected": true
}{
"detail": "text"
}Read a single tag
Reads one tag by name. A read that reaches the service but fails at the PLC returns 200 with error populated and value null, so check error as well as the status code.
Name of the tag to read, as configured in PLC_TAGS.
200Tag valueapplication/json
Tag data type. INT is a 16-bit signed integer (-32,768 to 32,767), DINT a 32-bit signed integer (-2,147,483,648 to 2,147,483,647), and REAL a 32-bit float.
BOOLINTDINTREALCurrent value, null when the read failed
Show propertiesHide properties
Read error, null on success
oknot_foundtype_mismatchnot_validated404Unknown tagapplication/json
Error message
422Request failed validationapplication/json
One entry in a request-validation failure.
Show propertiesHide properties
Path to the offending field, for example ["body", "value"].
503Service not initializedapplication/json
Error message
GET /read?tag=text HTTP/1.1
Host: device-ip:8007
Accept: application/jsoncurl -L \
--request GET \
--url 'http://device-ip:8007/read?tag=text' \
--header 'Accept: application/json'const response = await fetch("http://device-ip:8007/read?tag=text", {
method: "GET",
headers: {
"Accept": "application/json"
}
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/read?tag=text"
headers = {
"Accept": "application/json"
}
response = requests.get(url, headers=headers)
print(response.json()){
"name": "Station1.CycleCount",
"type": "DINT",
"description": "Total cycle count",
"writable": true,
"value": 42,
"last_updated": "2025-01-15T12:00:00+00:00",
"error": null,
"validation_status": "ok",
"validation_detail": null
}{
"detail": "text"
}{
"detail": [
{
"loc": [
"anything"
],
"msg": "text",
"type": "text"
}
]
}{
"detail": "text"
}Write a single tag
Writes one value to a configured writable tag. A write that reaches the PLC and fails there returns 200 with success: false and error populated.
Tag name to write
Value to write
Show propertiesHide properties
200Write resultapplication/json
Value written, null when the write failed
Show propertiesHide properties
403Tag is configured as not writableapplication/json
Error message
404Unknown tagapplication/json
Error message
422Request failed validationapplication/json
One entry in a request-validation failure.
Show propertiesHide properties
Path to the offending field, for example ["body", "value"].
503Service not initializedapplication/json
Error message
POST /write HTTP/1.1
Host: device-ip:8007
Content-Type: application/json
Accept: application/json
{
"name": "text",
"value": true
}curl -L \
--request POST \
--url 'http://device-ip:8007/write' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"name": "text",
"value": true
}'const response = await fetch("http://device-ip:8007/write", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
"name": "text",
"value": true
})
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/write"
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
payload = {
"name": "text",
"value": True
}
response = requests.post(url, headers=headers, json=payload)
print(response.json()){
"name": "text",
"value": true,
"success": true,
"error": "text"
}{
"detail": "text"
}{
"detail": "text"
}{
"detail": [
{
"loc": [
"anything"
],
"msg": "text",
"type": "text"
}
]
}{
"detail": "text"
}Batch Operations
Both batch endpoints validate as a unit before anything runs, so an unknown tag rejects the whole request rather than returning partial results.
/write_batch additionally rejects a batch that names the same tag twice, since keeping only the last value for a repeated name would silently drop the earlier writes. /read_batch accepts duplicates and returns one result per entry, in the order you sent them.
PLC failures that happen after validation are reported per entry in write_batch's results, with success_count and error_count summarizing the batch.
curl -X POST http://<device-ip>:8007/read_batch \
-H "Content-Type: application/json" \
-d '{"tags": ["Station1.PartPresent", "Station1.CycleCount"]}'Read several tags
Reads several tags in one request. Every name is checked before any read runs, so an unknown tag fails the whole request with 404 rather than returning partial results.
Tag names to read
200Tag valuesapplication/json
Show propertiesHide properties
Tag data type. INT is a 16-bit signed integer (-32,768 to 32,767), DINT a 32-bit signed integer (-2,147,483,648 to 2,147,483,647), and REAL a 32-bit float.
BOOLINTDINTREALCurrent value, null when the read failed
Show propertiesHide properties
Read error, null on success
oknot_foundtype_mismatchnot_validatedNumber of tags read
400Empty tag listapplication/json
Error message
404Unknown tag in the listapplication/json
Error message
422Request failed validationapplication/json
One entry in a request-validation failure.
Show propertiesHide properties
Path to the offending field, for example ["body", "value"].
503Service not initializedapplication/json
Error message
POST /read_batch HTTP/1.1
Host: device-ip:8007
Content-Type: application/json
Accept: application/json
{
"tags": [
"text"
]
}curl -L \
--request POST \
--url 'http://device-ip:8007/read_batch' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"tags": [
"text"
]
}'const response = await fetch("http://device-ip:8007/read_batch", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
"tags": [
"text"
]
})
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/read_batch"
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
payload = {
"tags": [
"text"
]
}
response = requests.post(url, headers=headers, json=payload)
print(response.json()){
"tags": [
{
"name": "text",
"type": "BOOL",
"description": "text",
"writable": true,
"value": true,
"last_updated": "text",
"error": "text",
"validation_status": "ok",
"validation_detail": "text"
}
],
"count": 1
}{
"detail": "text"
}{
"detail": "text"
}{
"detail": [
{
"loc": [
"anything"
],
"msg": "text",
"type": "text"
}
]
}{
"detail": "text"
}Write several tags
Writes several tags in one request. The whole batch is validated first: an empty list, a duplicate tag name, an unknown tag, or a tag that is not writable rejects the request before any write happens. Duplicates are rejected rather than collapsed, since silently keeping the last value for a name would drop the earlier writes.
Individual PLC failures after validation are reported per entry in results, with success_count and error_count summarizing the batch.
Show propertiesHide properties
Show propertiesHide properties
200Per-write resultsapplication/json
Show propertiesHide properties
Value written, null when the write failed
Show propertiesHide properties
400Empty writes list or duplicate tag name in the batchapplication/json
Error message
403A tag in the batch is configured as not writableapplication/json
Error message
404Unknown tag in the batchapplication/json
Error message
422Request failed validationapplication/json
One entry in a request-validation failure.
Show propertiesHide properties
Path to the offending field, for example ["body", "value"].
503Service not initializedapplication/json
Error message
POST /write_batch HTTP/1.1
Host: device-ip:8007
Content-Type: application/json
Accept: application/json
{
"writes": [
{
"name": "text",
"value": true
}
]
}curl -L \
--request POST \
--url 'http://device-ip:8007/write_batch' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"writes": [
{
"name": "text",
"value": true
}
]
}'const response = await fetch("http://device-ip:8007/write_batch", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
"writes": [
{
"name": "text",
"value": true
}
]
})
});
const data = await response.json();
console.log(data);import requests
url = "http://device-ip:8007/write_batch"
headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
payload = {
"writes": [
{
"name": "text",
"value": True
}
]
}
response = requests.post(url, headers=headers, json=payload)
print(response.json()){
"results": [
{
"name": "text",
"value": true,
"success": true,
"error": "text"
}
],
"success_count": 1,
"error_count": 1
}{
"detail": "text"
}{
"detail": "text"
}{
"detail": "text"
}{
"detail": [
{
"loc": [
"anything"
],
"msg": "text",
"type": "text"
}
]
}{
"detail": "text"
}CLI
The plc-cli tool gives an interactive terminal interface for reading and writing tags. Use it for debugging and quick operations when the web dashboard is not reachable. It is a local client running inside the container against the same HTTP API the dashboard uses.
docker exec -it plc-relay plc-cli| Key | Action |
|---|---|
R | Read all tag values |
T | Read a single tag, selected from a list |
W | Write a tag, selected from the writable tags |
S | Show the tag schema |
H | Show detailed health status |
V | Run tag validation against the PLC |
Enter | Refresh the display |
Q | Quit |
Environment Variables
The Configure modal writes these for you. Edit them directly only when a deployment is managed by hand. See Update Device Configuration.
| Variable | Default | Description |
|---|---|---|
PLC_DRIVER | none | allen_bradley, modbus, or siemens_s7 |
PLC_IP | none | PLC address in the format for the selected driver |
PLC_TAGS | none | Tag definitions as JSON. The config builder at /static/config-builder.html generates this |
SIMULATION_MODE | off | Use the in-memory simulator instead of a real PLC |
LOG_LEVEL | INFO | Logging verbosity: DEBUG, INFO, WARNING, or ERROR |
Driver-specific settings:
| Driver | Variable | Range | Default |
|---|---|---|---|
| Modbus | MODBUS_UNIT_ID | 0 to 255 | 1 |
| Modbus | MODBUS_WORD_ORDER | big or little | big |
| Siemens S7 | S7_RACK | 0 to 7 | 0 |
| Siemens S7 | S7_SLOT | 0 to 31 | 1 |
Connection Monitoring
The device page shows a live PLC Relay status card with the current connection state, the active protocol, and the latest tag values. When the relay cannot reach the PLC, the card displays an unreachable banner.
To be notified when a relay loses connectivity, add a "PLC Disconnected" alert from the device's "Device Alerts" tab. See Set up Device Alerts.
Troubleshooting
| Symptom | Fix |
|---|---|
| "PLC not connected" (Allen-Bradley) | Verify PLC address format (IP/Slot), check port 44818 is reachable |
| "PLC not connected" (Modbus) | Check IP/port (default 502) and verify Unit ID matches the device |
| "PLC not connected" (Siemens S7) | Check IP/port (default 102), rack, and slot values; for S7-1200/1500 enable PUT/GET and disable optimized block access |
| "Function refused" (Siemens S7) | PUT/GET disabled in TIA Portal, or optimized block access enabled on the target DB |
| REAL value reads as garbage (Modbus) | Try the opposite Word Order (big vs. little) |
| Validation shows NOT_FOUND | Check the PLC program for the exact tag name (case-sensitive) |