Skip to content

Edge (PLCnext / Orin)

The Consystence edge runtime (consystence-edge) is a self-contained .NET 10, single-silo Orleans application that sits between the PLCnext border gateway and the site server. It polls controller tags, executes commands, buffers data through upstream outages, and isolates the IT network from the OT network. This page covers how to publish and deploy it onto an edge host and how to wire up its controller drivers.

For the conceptual model — what the edge does and how it talks to controllers — start at Edge runtime and PLC communication.

flowchart LR
  SS["Site server<br/>(ServerMode.Site)"]
  subgraph EDGE["Edge host — VL3 (primary) or Orin (variant)"]
    ER["consystence-edge<br/>.NET 10 single-silo Orleans<br/>SQLite store-and-forward"]
  end
  BG["PLCnext border gateway<br/>GDS / PORT variables"]
  FIELD["Field controllers<br/>ControlLogix / PLCnext / Modbus"]

  SS <-->|"gRPC (HTTP/2 TLS)"| ER
  ER -->|"gRPC over UDS<br/>/run/plcnext/grpc.sock"| BG
  BG -->|"CIP / EtherNet-IP (TCP 44818)"| FIELD
  ER -.->|"direct CIP / Modbus TCP<br/>(third-party interop)"| FIELD

The primary data path is edge → PLCnext GDS over gRPC → PLCnext border gateway → CIP/EtherNet-IP to the field. Direct native drivers from the edge to a controller (ethernet-ip, modbus-tcp) are the supported interop path for third-party and legacy equipment — prefer the gateway for Consystence-managed devices. See PLC communication for the protocol detail.

VL3 (primary)Nvidia Orin (variant)
RolePLCnext controller + edge co-locatedGPU-capable edge alongside an external controller
Field accessPLCnext GDS over gRPC (UDS)Direct CIP, or gRPC to a separate PLCnext
Runtime IDlinux-x64 (Atom-class) / linux-arm64 (ARM-class)linux-arm64
Deploymentsystemd service or Podman containersystemd service

The VL3 is the default. Orin is for sites that need GPU-class compute beside the controller.

consystence-edge publishes as a self-contained binary — no .NET runtime needs to be installed on the edge host. Choose the runtime identifier that matches the controller’s CPU:

Terminal window
cd edge
# Atom-class VL3 EDGE:
dotnet publish Consystence.Edge -c Release -r linux-x64 --self-contained true
# ARM-class PLCnext or Nvidia Orin:
dotnet publish Consystence.Edge -c Release -r linux-arm64 --self-contained true

The publish output is the deployable unit. The executable is Consystence.Edge; it exposes a local diagnostics API and a /health endpoint on port 8080.

The runtime runs as a hardened systemd service under a dedicated consystence user. Install the published output and the unit file:

Terminal window
# Binary + config
sudo mkdir -p /opt/consystence/edge
sudo cp -r publish/* /opt/consystence/edge/
# Data + log directories (writable by the service)
sudo mkdir -p /var/lib/consystence /var/log/consystence
sudo chown consystence:consystence /var/lib/consystence /var/log/consystence
# Service
sudo cp config/consystence-edge.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now consystence-edge

Key properties of the unit:

PropertyValueWhy
TypenotifyThe runtime signals readiness to systemd once started
ExecStart/opt/consystence/edge/Consystence.EdgeThe self-contained binary
User / GroupconsystenceLeast privilege
ProtectSystemstrictRead-only filesystem except declared paths
ReadWritePaths/var/lib/consystence, /var/log/consystenceBuffer, identity, logs
StateDirectoryconsystencesystemd owns and persists /var/lib/consystence across unit replacement
MemoryMax512MResource cap
Restartalways (10s)Survive crashes

/var/lib/consystence is load-bearing: it holds the SQLite store-and-forward buffer and the write-once provisioned install identity + offline-licence state (per licensing). It must survive restarts and redeploys — StateDirectory guarantees this for the systemd path.

On a VL3 the edge can also run as an OCI container under Podman alongside the PLCnext runtime container. The container is built from the repo Dockerfile (multi-stage, linux/arm64 or linux/amd64). Two things matter for a correct container run:

  1. Bind-mount the PLCnext gRPC socket read-only so the driver can reach the local GDS: -v /run/plcnext/grpc.sock:/run/plcnext/grpc.sock:ro
  2. Mount a named volume at /var/lib/consystence so the install identity and buffer survive container recreation: -v consystence-edge-data:/var/lib/consystence
Terminal window
podman run -d \
--name consystence-edge \
--network host \
--restart unless-stopped \
-e Upstream__ServerUrl=https://site-server.local:5001 \
-e Upstream__ForceHttp3=false \
-v /run/plcnext/grpc.sock:/run/plcnext/grpc.sock:ro \
-v consystence-edge-data:/var/lib/consystence \
consystence-edge:latest

--network host is used so the container can reach both the PLCnext UDS and the upstream server. Without the /var/lib/consystence volume the container re-mints a new install identity on every recreation.

All edge configuration is local: the runtime reads standard .NET configuration (appsettings.json plus environment variables such as Edge__Id and Upstream__ServerUrl) on every boot. Server-pushed configuration is a roadmap item; today the full driver configuration below is maintained in appsettings.json on the device.

The minimal configuration is the mandatory edge identity plus the upstream URL:

{
"Edge": {
"Id": "edge-001"
},
"Upstream": {
"ServerUrl": "https://site-server.local:5001",
"ForceHttp3": false,
"BufferPath": "/var/lib/consystence/buffer.db"
}
}

Edge:Id is required — startup throws Edge:Id is required in configuration without it; nothing auto-detects a serial or MAC. The local API port is set via the Kestrel section of appsettings.json or ASPNETCORE_URLS (the systemd unit sets http://+:8080).

The edge registers with the site server on every boot and reconnect, sending its configured Edge:Id as the hardware_id (plus hostname, version, and capabilities). The server resolves that id to a site — auto-registering an unknown edge in a single-site org; in a multi-site org it stays PENDING until registered via the site admin surface — and returns APPROVED, PENDING, or REJECTED; on approval the response echoes the edge_id and carries the org slug as tenant_code. No registration identity is persisted between boots — the write-once install identity under /var/lib/consystence is the separate licensing identity and plays no part in registration.

The full configuration is plain JSON (appsettings.json on the device). The deployment-relevant sections are the controller list, the poll groups, the device instances, and the command mappings.

Each entry selects a driver via the IPlcDriver abstraction. Valid Driver values are plcnext-grpc, ethernet-ip, modbus-tcp, and stub.

"Plcs": [
{
"PlcId": "plcnext-001",
"Name": "PLCnext VL3 (local)",
"Driver": "plcnext-grpc",
"Address": "localhost",
"Port": 0,
"Slot": 0,
"TimeoutMs": 5000,
"ReconnectIntervalMs": 10000
}
]
DriverTransportUse
plcnext-grpcPLCnext GDS over gRPC, default via UDS /run/plcnext/grpc.sockPrimary — read/write GDS/PORT variables on the border gateway
ethernet-ipPure-.NET EtherNet/IP CIP stack, TCP 44818Direct to a ControlLogix/CompactLogix — third-party / legacy-controller interop
modbus-tcpPure-.NET Modbus TCP, TCP 502Direct to a Modbus TCP device — register addresses go in the tag path with an explicit data type and word order
stubIn-process simulatorDevelopment and CI only

For the plcnext-grpc driver, the PLCnext connection itself is configured under a dedicated block. The default is the local Unix domain socket; a TCP gRPC endpoint can be supplied where the runtime is reached over the network (a networked PLCnext runtime, for example, is reached over an explicit host and port):

"PlcNext": {
"SocketPath": "/run/plcnext/grpc.sock",
"ComponentPath": "Arp.Plc.Eclr/ExampleComponent.ExampleProgram",
"SubscriptionSampleRateUs": 0
}

Tags are grouped by polling interval; each group runs its own timer and reads via the PLCnext subscription API for task-consistent values. A tag maps a device instance’s logical tag name to the physical PLC tag path on a specific controller.

"PollGroups": [
{
"GroupId": "plcnext-fast",
"IntervalMs": 250,
"Tags": [
{ "InstanceId": "dev_k7m2p9qx", "TagName": "Running", "PlcId": "plcnext-001", "PlcTagPath": "PP01_Running" },
{ "InstanceId": "dev_k7m2p9qx", "TagName": "Speed", "PlcId": "plcnext-001", "PlcTagPath": "PP01_Speed" }
]
}
]

Each instance binds a per-site device to a reusable device type (dotted TypeId, e.g. consystence.pump.centrifugal) and maps its type-defined tag names to physical PLC tag paths.

"Instances": [
{
"InstanceId": "dev_k7m2p9qx",
"Name": "Dam Transfer Pump 1",
"TypeId": "consystence.pump.centrifugal",
"TypeVersion": "1.0.0",
"TagMappings": {
"Running": "PP01_Running",
"Speed": "PP01_Speed",
"Current": "PP01_Current"
}
}
]

Commands are keyed by {instanceId}:{commandName} and resolve to one or more tag writes on a target controller. Parameters are substituted with {paramName} syntax.

"CommandMappings": {
"dev_k7m2p9qx:start": {
"PlcId": "plcnext-001",
"Writes": [ { "TagPath": "PP01_Cmd_Start", "Value": "true" } ]
},
"dev_k7m2p9qx:set-speed": {
"PlcId": "plcnext-001",
"Writes": [ { "TagPath": "PP01_Cmd_SetSpeed", "Value": "{speed}" } ]
}
}

The edge streams tag batches to the site server over multiplexed gRPC over HTTP/2. Set Upstream:ForceHttp3 to false in this release: when the flag is true (the shipped default) the edge requests HTTP/3 (QUIC) exclusively, which the site server does not yet serve, and the upstream link cannot connect. When the upstream link drops, polling continues and batches are persisted to a WAL-mode SQLite buffer at /var/lib/consystence/buffer.db. On reconnect the buffer flushes in monotonic sequence order; the server acknowledges sequences and acknowledged messages are deleted. The buffer survives restarts and power cycles.

The buffer is disk-capped via Upstream:BufferMaxBytes (default 536870912 — 512 MiB). On overflow the oldest data is dropped first; drops are counted and reported via the heartbeat, and the edge reports degraded health while overflowing. Size the cap for the longest upstream outage the site must ride out.

"Upstream": {
"ServerUrl": "https://site-server.local:5001",
"ForceHttp3": false,
"BufferPath": "/var/lib/consystence/buffer.db",
"BufferMaxBytes": 536870912,
"HeartbeatIntervalMs": 30000,
"MaxBatchSize": 200,
"FlushIntervalMs": 500
}

On a dual-NIC host the edge applies an nftables ruleset for network isolation: IT traffic (upstream) on one interface, OT traffic (field) on the other, with forwarding between them blocked. A passive OT monitor watches /proc/net/ counters for unexpected sources, unexpected ports, and traffic spikes.

"Firewall": {
"Enabled": true,
"ItInterface": "eth0",
"OtInterface": "eth1",
"BlockForwarding": true,
"ItRules": {
"InboundAllow": [
{ "Port": 22, "Protocol": "tcp", "Description": "SSH" }
],
"OutboundAllow": [
{ "Port": 5001, "Protocol": "tcp", "Description": "gRPC upstream (TLS)" },
{ "Port": 53, "Protocol": "tcp+udp", "Description": "DNS" },
{ "Port": 123, "Protocol": "udp", "Description": "NTP" }
]
},
"OtRules": {
"InboundAllow": [
{ "Port": 44818, "Protocol": "tcp+udp", "Description": "EtherNet/IP CIP", "AllowedSources": ["192.0.2.10"] }
],
"OutboundAllow": [
{ "Port": 44818, "Protocol": "tcp+udp", "Description": "EtherNet/IP CIP", "AllowedSources": ["192.0.2.10"] }
]
}
}

The generated ruleset is default-drop: only established/related and loopback traffic pass unless allow rules are declared under ItRules/OtRules (each rule takes Port, Protocoltcp, udp, or tcp+udp — a Description, and an optional AllowedSources IP list). There are no built-in allow rules — the shipped appsettings.json declares none, so enabling the firewall without declaring rules blocks the upstream connection too. The example above yields the intended posture: the OT interface permits EtherNet/IP CIP (TCP+UDP 44818) only, restricted to the declared controller IPs, and the IT interface permits only SSH in and upstream/DNS/NTP out. Firewall application is non-fatal at startup — a failure is logged, not crashed.

Edge licensing follows the platform licensing model (see Licensing):

  • Provisioned identity, not hardware ID. The licence binds to a write-once registration secret persisted under /var/lib/consystence — not a motherboard/MAC fingerprint. This survives the snapshot/clone/migrate operations the fleet runs constantly.
  • Account-issued, expiring, per-device. Each edge consumes one entitlement from the org’s Edge seat pool; the licence carries an expiry and must be renewed online or reissued via an offline file before it lapses.
  • Cell-mediated. Issuance authority lives in the account control plane; the cell mediates and the edge enforces.
  • Offline = account-signed. The offline bundle is verified against an account ECDSA key published as JWKS — no live call needed to validate a held entitlement.
  • Degrade, never kill. A lapsed licence degrades the edge to a constrained mode (no new deploys/expansion). It never gates operator control writes (Start/Stop/Reset/setpoint/ack) and never stops the poll loop.
"Licence": {
"BundlePath": "/var/lib/consystence/edge-licence.bundle.json"
}

The edge exposes a minimal, read-only HTTP API on port 8080 for on-device diagnostics:

MethodPathDescription
GET/healthASP.NET health check (used by systemd/container probes)
GET/api/statusController, upstream, and buffer status
GET/api/firewallCurrent nftables ruleset and status
GET/api/networkOT traffic metrics and detected anomalies