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.
Topology
Section titled “Topology”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.
Host targets
Section titled “Host targets”| VL3 (primary) | Nvidia Orin (variant) | |
|---|---|---|
| Role | PLCnext controller + edge co-located | GPU-capable edge alongside an external controller |
| Field access | PLCnext GDS over gRPC (UDS) | Direct CIP, or gRPC to a separate PLCnext |
| Runtime ID | linux-x64 (Atom-class) / linux-arm64 (ARM-class) | linux-arm64 |
| Deployment | systemd service or Podman container | systemd service |
The VL3 is the default. Orin is for sites that need GPU-class compute beside the controller.
Build and publish
Section titled “Build and publish”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:
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 trueThe publish output is the deployable unit. The executable is Consystence.Edge; it exposes a local diagnostics API and a /health endpoint on port 8080.
systemd deployment
Section titled “systemd deployment”The runtime runs as a hardened systemd service under a dedicated consystence user. Install the published output and the unit file:
# Binary + configsudo mkdir -p /opt/consystence/edgesudo cp -r publish/* /opt/consystence/edge/
# Data + log directories (writable by the service)sudo mkdir -p /var/lib/consystence /var/log/consystencesudo chown consystence:consystence /var/lib/consystence /var/log/consystence
# Servicesudo cp config/consystence-edge.service /etc/systemd/system/sudo systemctl daemon-reloadsudo systemctl enable --now consystence-edgeKey properties of the unit:
| Property | Value | Why |
|---|---|---|
Type | notify | The runtime signals readiness to systemd once started |
ExecStart | /opt/consystence/edge/Consystence.Edge | The self-contained binary |
User / Group | consystence | Least privilege |
ProtectSystem | strict | Read-only filesystem except declared paths |
ReadWritePaths | /var/lib/consystence, /var/log/consystence | Buffer, identity, logs |
StateDirectory | consystence | systemd owns and persists /var/lib/consystence across unit replacement |
MemoryMax | 512M | Resource cap |
Restart | always (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.
Container deployment (Podman)
Section titled “Container deployment (Podman)”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:
- 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 - Mount a named volume at
/var/lib/consystenceso the install identity and buffer survive container recreation:-v consystence-edge-data:/var/lib/consystence
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.
Bootstrap and registration
Section titled “Bootstrap and registration”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.
Driver configuration
Section titled “Driver configuration”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.
Controllers (Plcs)
Section titled “Controllers (Plcs)”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 }]Driver | Transport | Use |
|---|---|---|
plcnext-grpc | PLCnext GDS over gRPC, default via UDS /run/plcnext/grpc.sock | Primary — read/write GDS/PORT variables on the border gateway |
ethernet-ip | Pure-.NET EtherNet/IP CIP stack, TCP 44818 | Direct to a ControlLogix/CompactLogix — third-party / legacy-controller interop |
modbus-tcp | Pure-.NET Modbus TCP, TCP 502 | Direct to a Modbus TCP device — register addresses go in the tag path with an explicit data type and word order |
stub | In-process simulator | Development 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}Poll groups (PollGroups)
Section titled “Poll groups (PollGroups)”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" } ] }]Instances (Instances)
Section titled “Instances (Instances)”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" } }]Command mappings (CommandMappings)
Section titled “Command mappings (CommandMappings)”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}" } ] }}Store-and-forward and upstream
Section titled “Store-and-forward and upstream”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}IT/OT isolation
Section titled “IT/OT isolation”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, Protocol — tcp, 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.
Licensing
Section titled “Licensing”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"}Local diagnostics API
Section titled “Local diagnostics API”The edge exposes a minimal, read-only HTTP API on port 8080 for on-device diagnostics:
| Method | Path | Description |
|---|---|---|
GET | /health | ASP.NET health check (used by systemd/container probes) |
GET | /api/status | Controller, upstream, and buffer status |
GET | /api/firewall | Current nftables ruleset and status |
GET | /api/network | OT traffic metrics and detected anomalies |
See also
Section titled “See also”- Edge runtime — the conceptual overview
- PLC communication — GDS-over-gRPC and CIP detail
- Licensing — the full entitlement model
- Deployment — Azure and on-prem server deployment
- Cell topology — where account-issued licences originate