HEART Embedded Systems logoHEART

Water pump station: on a real device#

A two-pump station with a HEART on an edge gateway in the control panel, reading the PLC over Modbus TCP, read only: the parts, the network, the configuration, how to install it, the commissioning steps, and what the operators see.

For illustration only. The hardware, the register map and the addresses below are assumptions, chosen so the steps can be concrete. Your station, PLC and network will differ. This product has not been built. heartd and its Modbus TCP adapter have been tested only on an x86-64 Linux virtual machine, against the small simulated Modbus device in heart-core-rs/examples/pump-station and a mock model: never against a real PLC, never on an industrial gateway, never at a real station. Treat this page as a plan to check on a test rig, under your utility's own engineering and change procedures.

PlacementB: beside a controller. The HEART runs on an edge gateway in the panel. The PLC and its interlocks run the station; HEART only reads the PLC's Modbus map.
Start fromheartd, the Rust Core's daemon, with its Modbus TCP adapter. heart-core-rs/examples/pump-station is the pattern.
ClaimHEART Basic, Autonomy Level A1. This HEART changes nothing: it has no commands at all.
Try it firstThe simulator, the design case study, and heartd's quick start with its simulated pump

1. The station we assume#

PartAssumed choiceNotes
PLCA compact PLC with a built-in Modbus TCP server and analogue input cards, such as a Siemens S7-1200 or a Schneider Modicon M241Already there. Runs the pumps, duty and standby, and every trip. Never part of HEART.
Sump levelA hydrostatic level transmitter, 4–20 mA for 0–5 m (LT-101, as in the simulator), into the PLCHEART watches it for sticking
Low-level float switchHardwired into the PLC's dry-run input and the motor startersThe dry-run protection. HEART sees it only as a copied bit.
Reservoir levelReaches the PLC from the reservoir over the existing telemetry or a cable
FlowAn electromagnetic flowmeter on the rising main, 4–20 mA for 0–500 m³/h
Discharge pressureA pressure transmitter, 4–20 mA for 0–10 barThe PLC's high-pressure trip uses it, with a local reset
Motor windingsA PT100 in each motor winding, into an RTD card on the PLC (or a motor protection relay)The PLC trips at 120 °C
VibrationA 4–20 mA vibration transmitter (RMS velocity, 0–25 mm/s) on each pump's bearing housing
Mains and UPSA voltage transducer into the PLC; a 24 V DC UPS whose "on battery" and charge outputs go to the PLCThe PLC and the gateway both run from the UPS
Edge gatewayA DIN-rail industrial Linux computer with two Ethernet ports, a relay output and a 24 V DC input, such as a Siemens SIMATIC IOT2050 or a Revolution Pi ConnectRuns heartd and two small helpers
RouterThe existing telemetry router (fibre or cellular), with its firewallCarries reports to the control room and the monitor site
Alarm lampA separate "HEART" lamp on the panel door, on the gateway's relay outputTells a crew on site, with no network. It is not one of the PLC's alarms.

Check your own: card types, ranges and trip settings come from the station's drawings and commissioning records.

Scaling. The PLC turns each 4–20 mA input into an integer in engineering units, as the simulator's map does. For example, the flowmeter at 12 mA: (12 − 4) / 16 × 500 = 250 m³/h, written as 2500 in units of 0.1 m³/h.

The register map HEART reads#

The simulator's map (Section 5 of its README) puts the values in input registers (function 4) and discrete inputs (function 2), which are read-only tables. heartd's adapter today reads only holding registers (function 3), and can write one (function 6). The guide gives two ways round this (Step 1): have the PLC mirror the values into a block of holding registers, or add functions 2 and 4 to the adapter. This page assumes the mirror, because it works with heartd as it is:

Holding register (0-based, on the wire)Copied fromScale, unit
100Input register 0: sump level0.001 m
101Input register 1: reservoir level0.001 m
102Input register 2: station flow0.1 m³/h
103Input register 3: discharge pressure0.01 bar
104, 105Input registers 4, 5: pump 1, pump 2 motor current0.1 A
106, 107Input registers 6, 7: pump 1, pump 2 winding temperature0.1 Cel
108, 109Input registers 8, 9: pump 1, pump 2 vibration0.01 mm/s
110 to 113Input registers 10 to 13: run hours (32 bits each, high word first)0.1 h
114Input register 14: mains voltage0.1 V
115Input register 15: the PLC status word (bit 0, 1 pump thermal trip; 2 high-pressure trip; 3 dry-run stop; 4 mains lost; 5 link down; 6 duty is pump 2; 7 PLC calls for water)bits
116Input register 16: outflow to the town0.1 m³/h
117Input register 17: UPS charge1 %
118Discrete inputs 0 to 4, as bits 0 to 4: pump 1 running, pump 2 running, float switch low, UPS on battery, telemetry link upbits

Addresses are 0-based protocol addresses, as the Signal Tables require. (heartd also accepts old reference numbers: it subtracts 40001 from any number of 40001 or more, so 40101 would mean address 100. Do not mix the two.)

Rules for the mirror block, for the PLC engineer:

  • The PLC program writes it and never reads it back. Nothing in the program may depend on it.
  • It is refused to every network write, if the PLC can do that. If it cannot, the program overwrites the whole block every scan, so a stray write lasts at most one scan and controls nothing.
  • It is a PLC program change: make it under the utility's own change procedure, and test it in the PLC's simulator first.

Network#

Address (assumed)What
10.20.1.10PLC, on the station control VLAN 10.20.1.0/24
10.20.1.20Gateway port 1, on the control VLAN
10.20.2.20Gateway port 2, on the telemetry VLAN 10.20.2.0/24, towards the router
FlowAllowed
Gateway 10.20.1.20 → PLC 10.20.1.10, TCP 502Yes. Modbus function 3 only, if the firewall or managed switch can filter Modbus function codes (many industrial firewalls can).
Anything → PLC from the gateway, other than the aboveNo
PLC → gatewayNo new connections
Control room SCADA → gateway 10.20.2.20, TCP 43278Yes: the HEART API, over TLS
Gateway → the monitor site, TCP 443Yes: HTTPS reports, outbound, to that one host
Gateway → the utility's time server, UDP 123Yes: signed requests and timestamps need a good clock
Engineering workstation → gateway, SSHYes, from that workstation only
Everything elseNo

The gateway does not route between its two ports (net.ipv4.ip_forward = 0). The rule that matters most, "the gateway may only read from the PLC", must be enforced outside the gateway, by the firewall or switch in front of the PLC and by the PLC itself: a gateway that has been broken into could change its own firewall.


2. Where everything runs#

sensors, float switch, pumps ──(4-20 mA, wired I/O)──► PLC: runs the station, holds every trip and interlock
                                                       │ Modbus TCP, port 502, function 3 only
                                                       │ (firewall: the gateway may only read)
┌──────────────── edge gateway (Linux, in the panel) ──▼──────────────────────────┐
│ heartd: polls its property definitions, applies rules, serves the HEART API     │
│ heart-pump-feeder: reads registers 100-118, sends vitals and events ─► host socket
│ heart-monitor-report: public Pulse and alerts ──────────────────────────────────────► monitor site (HTTPS, outbound)
│                       relay output ──► HEART lamp on the panel door             │
└──────────────────────────────┬──────────────────────────────────────────────────┘
                               │ HEART API, TLS, port 43278
                               ▼
                    router ──► control room SCADA (reads alerts; pages the on-call operator)
  • The PLC does not know HEART is there. If the gateway crashes, is removed or is hacked, the PLC runs the station exactly as before (Part 6, Section 9, rules 1, 3 and 6).
  • HEART reads safety signals; it never writes them. The trip bits and the float switch reach it only as copies in registers 115 and 118.
  • Two readers, both read only. heartd polls its property definitions, so operators can read each value. heartd's rules do not yet watch polled properties (only values from its host feed), so a small feeder reads the same block once every 5 s and sends what the rules need (3.4).

3. The configuration#

FileWhereWhat you do
heartd/usr/local/bin/heartdBuild it (Section 4)
heartd.toml/etc/heart/heartd.tomlThe daemon's settings (3.1)
card.json/etc/heart/card.jsonWho the station is (3.2)
custom/*.json/etc/heart/custom/One read-only property definition per register (3.3)
rules.json/etc/heart/rules.jsonWhat becomes an alert, with which fault code (3.5)
heart-pump-feeder.py/usr/local/lib/heart/You write it (3.4)
heart-monitor-report.py/usr/local/lib/heart/You write it, from the Python template (3.6)

Relative paths in heartd.toml are read from the file's own folder, and unknown keys are refused (CONFIGURATION.md). The example's heartd.toml, card.json, custom/tank-level.json and rules.json are the pattern. Leave out its command custom/set-flow.json: it writes a register with function 6.

3.1 heartd.toml#

[system]
card = "card.json"
instance = "station-01"                    # a label only: never a place name
custom_dir = "custom"
rules = "rules.json"

[server]
bind = "10.20.2.20"                        # the telemetry-side port, for SCADA; off loopback, TLS is required
tls_cert = "tls/gateway.crt"               # /etc/heart/tls/, root:heart, key mode 0640
tls_key = "tls/gateway.key"
unix_socket = "/run/heart/api.sock"        # for the two helpers on the gateway
host_socket = "/run/heart/host.sock"       # the feeder's input, mode 0600

[auth]
issuer = "https://auth.water.example"      # your authorisation server
public_key = "<base64 of its raw Ed25519 public key>"

[storage]
state_file = "/var/lib/heart/state.json"  # the identity key is kept beside it

[core]
mode = "observe"                           # takes no action
autonomy_level = 1
escalate_after_sec = 600                   # an error or critical alert open for 10 min is marked escalated
escalate_to = "on-call operator"           # a label recorded on the alert

Leave out [model], [brain], [presence] and [uplink]: no AI model, nothing to approve, no Gateway. SCADA reads the internal values with an access token from your authorisation server, with heart_clearance internal. Off the loopback address, heartd refuses tokens that are not bound to the caller with DPoP (Security Advisory HEART-2026-001), so SCADA's HEART client must support that.

3.2 card.json#

{ "system": { "id": "org.example.water.pump-station", "name": "Example pump station", "version": "1.0.0" },
  "level": "basic", "bindings": ["http-server", "unix-socket"], "pulse": { "intervalSec": 30 },
  "modules": ["properties"], "shares": { "pulse": "public", "arrhythmia": "public", "property": "internal" },
  "autonomy": { "level": 1 }, "capabilities": [],
  "ext": { "org.heartcore.poll": { "sec": 10 } } }

The Card offers properties and nothing else. Without the custom and native modules, nobody can add a mapping or send a raw Modbus request through the API: those operations answer 501. org.heartcore.poll sets how often heartd reads its properties (the example uses 1 s; the default is 5 s).

3.3 The property definitions#

Draft them from the register map with the signal tool, then let a person check and seal them. The CSV columns are name,title,table,register,type,scale,unit,min,max,writable,description:

name,title,table,register,type,scale,unit,min,max,writable,description
sump_level,Sump level (LT-101),holding,100,int,0.001,m,0,5,,Copied from input register 0
station_flow,Station flow,holding,102,int,0.1,m3/h,0,500,,Copied from input register 2
p1_winding,Pump 1 winding temperature,holding,106,int,0.1,Cel,-20,180,,Copied from input register 6
plc_status,PLC status word,holding,115,int,1,,0,65535,,Copied from input register 15
python3 heart-templates/tools/heart_signals.py draft --system org.example.water.pump-station \
  --modbus registers.csv --modbus-target tcp:10.20.1.10:502:1 > signals.json
python3 heart-templates/tools/heart_signals.py check signals.json
python3 heart-templates/tools/heart_signals.py seal signals.json --by "A. Engineer"
python3 heart-templates/tools/heart_signals.py export signals.json --out custom/

The draft makes nothing writable, and marks names that suggest a safety function (trip, interlock) as safety. Check every unit it converted. export writes one file per signal, in the format of the example's custom/tank-level.json. Sump level:

{
  "name": "org.example.water.pump-station.sump_level",
  "kind": "property",
  "title": "Sump level (LT-101)",
  "description": "Copied from input register 0",
  "valueSchema": { "type": "number", "minimum": 0, "maximum": 5 },
  "writable": false,
  "privacy": "internal",
  "native": {
    "protocol": "modbus-tcp",
    "target": "tcp:10.20.1.10:502:1",
    "request": { "function": 3, "register": 100, "count": 1 },
    "response": { "extract": { "value": { "from": "registers[0]", "scale": 0.001 } } },
    "timeoutMs": 1000
  },
  "unit": "m"
}

Before you install them: check that every file says "function": 3 and "writable": false, and that there is no kind command. The format is in the API Standard, Section 14.

Two limits of the adapter today:

  • One short TCP connection per value. heartd opens a connection for each property it reads. Twelve properties every 10 s is 12 / 10 = 1.2 connections a second: check that your PLC's Modbus server allows it, and keep the list short.
  • One register per value. The extract shown reads registers[0]. Leave the 32-bit run hours out, or have the PLC also write them in a single register.

Every value is internal, as in the guide's Step 3. Nothing is private, because nothing is personal.

3.4 The feeder#

heart-pump-feeder.py reads registers 100 to 118 in one function 3 request every 5 s, works out the guide's conditions, and sends them to heartd's host feed: one JSON line per value or event.

import json, socket

def heart_feed(line):
    """One way: values go to heartd, nothing comes back but ok."""
    with socket.socket(socket.AF_UNIX) as s:
        s.settimeout(1)
        s.connect("/run/heart/host.sock")
        s.sendall((json.dumps(line) + "\n").encode())
        s.recv(256)

def poll(plc, seen):
    """CHANGE: plc is your Modbus TCP client. Read only: function 3, nothing else."""
    r = plc.read_holding_registers(100, 19)          # registers 100 to 118
    status, bits = r[15], r[18]
    running = [bool(bits & 1), bool(bits & 2)]
    flow = r[2] / 10
    tripped = [bool(status & 1), bool(status & 2)]
    station_off = status & (4 | 8 | 16)              # high-pressure trip, dry-run stop, mains lost
    available = 0 if station_off else sum(not t for t in tripped)
    if available == 0 or (status & 128 and not any(running)):
        heart_feed({"event": "station.cannot-pump", "severity": "critical", "component": "station"})
    else:
        heart_feed({"recovered": "station"})
    heart_feed({"vital": "p1_winding", "value": seen.lowest("p1_winding", r[6] / 10)})   # lowest of the last 5 s
    ...
What the feeder sendsWhenComponent
Event station.cannot-pumpNo pump available (tripped, locked out, dry-run, no mains), or the PLC calls (bit 7) and nothing runsstation
Event station.cavitationA pump runs with vibration above 7.1 mm/s and station flow below 75 % of its commissioned flow. Assume 300 m³/h commissioned: 0.75 × 300 = 225 m³/hp1-cavitation, p2-cavitation
Event station.level-stuckThe sump level register has not changed for 90 s while flow is above 10 m³/hlt-101
Vitals p1_winding, p2_windingEvery 5 s, the lowest reading of those 5 s, so one odd reading is not a faultthe vital's name
Event station.mains-lostStatus bit 4, or "UPS on battery"mains
Event station.link-lostStatus bit 5, or "telemetry link up" is 0telemetry
{"recovered": "<component>"}The condition has cleared (a winding back below 95 °C: an assumed 5 °C hysteresis)Resolves every open alert of that component

If the PLC does not answer, the feeder sends nothing and never guesses. heartd notices by itself (the rule plc-silent below). The socket is mode 0600, so the feeder runs as user heart, like heartd.

3.5 rules.json#

Each line of the guide's fault table becomes a Tending rule. The utility adopts them by writing "status": "adopted" (with adoptedAt, as the rule schema asks). They only raise alerts: none has a capability.

[
  { "id": "cannot-pump", "enabled": true, "status": "adopted", "origin": "owner", "adoptedAt": "2026-11-12T09:00:00Z",
    "description": "The station cannot pump water",
    "when": { "event": "station.cannot-pump" },
    "then": { "raise": { "code": "system.main-function-lost", "severity": "critical",
              "summary": "The station cannot pump water. Check the reservoir level, and send the on-call operator." } } },
  { "id": "p1-hot", "enabled": true, "status": "adopted", "origin": "owner", "adoptedAt": "2026-11-12T09:00:00Z",
    "description": "Pump 1 winding at 100 Cel or more; the PLC trips at 120",
    "when": { "signal": "p1_winding", "op": ">=", "value": 100 },
    "then": { "raise": { "code": "performance.too-hot", "severity": "warning",
              "summary": "Pump 1's motor winding is at 100 °C or more. The PLC trips it at 120 °C. Check its cooling fan." } } },
  { "id": "plc-silent", "enabled": true, "status": "adopted", "origin": "owner", "adoptedAt": "2026-11-12T09:00:00Z",
    "description": "The PLC stops answering the gateway",
    "when": { "event": "component.failed", "component": "tcp:10.20.1.10:502:1", "count": 3, "windowSec": 30 },
    "then": { "raise": { "code": "connectivity.dependency-unreachable", "severity": "error",
              "summary": "The PLC is not answering the gateway. The station runs without HEART; its values here are old." } } }
]
RulewhenFault codeSeverity
cannot-pumpevent station.cannot-pumpsystem.main-function-lostcritical
cavitationevent station.cavitationsystem.main-function-degradedwarning
level-stuckevent station.level-stucksensors.stuckwarning
p1-hot, p2-hotp1_winding, p2_winding >= 100performance.too-hotwarning
mains-lostevent station.mains-lostpower.supply-losterror
link-lostevent station.link-lostconnectivity.link-lostwarning
plc-silentheartd's own event component.failed for the PLC's target, 3 times in 30 sconnectivity.dependency-unreachableerror

Every code is in Part 3, Section 5.4. heartd raises component.failed each time a property read fails, and resolves the alert by itself when the PLC answers again. HEART's thresholds sit below the PLC's trips on purpose: a person hears that a motor is hot before the PLC stops it, and the PLC stops it whether or not HEART is there. Take the real numbers from the pumps' commissioning curves, ISO 10816 or ISO 20816 for vibration, and the motor maker's data.

The Pulse follows the worst open alert: critical makes it failing; error or warning make it degraded.

3.6 Reporting, and the lamp#

heartd has no HEART Hub setting: there is no such section in CONFIGURATION.md. Write heart-monitor-report.py from the Python template's HEART Hub code (templates README): copy monitor_identity(), monitor_post() and the hello and report loop of monitor(), and fill each report from heartd:

  1. Read /heart/v1/pulse and /heart/v1/arrhythmias through /run/heart/api.sock (public views, no token). For example: curl --unix-socket /run/heart/api.sock http://localhost/heart/v1/pulse.
  2. Copy each alert's kind into code. heartd does not yet put the Part 3 code on its alerts (CONFORMANCE.md lists it as not implemented); the rule's raise.code ends up in kind. A monitor site refuses an alert without code.
  3. Send at most 16 alerts, newest first, as in HEART Hub, Section 5. If heartd does not answer, send nothing, so the page shows Not heard from.
  4. Light the lamp on the panel door while the Pulse is not healthy, through the gateway's relay output (for example with gpioset from libgpiod). This needs no network: when the telemetry link is down, the lamp is the only route.

Which monitor site. The guide's warning holds: even "this station has lost its main function" tells someone when a town's water is vulnerable. A utility should run its own monitor site (the code is open, in heart-monitor/), inside its own network rules. https://heartcortex.ai is for a trial on a test rig, not for live plant.


4. Build and install#

Build heartd for the gateway. For example, on an arm64 Linux machine for an IOT2050 or a Revolution Pi (or on x86-64 for an x86 gateway):

cd heart-core-rs
cargo build --release -p heartd

It has not been built for ARM in this project yet. Try it first against the example's simulator, as in the quick start.

Install it on the gateway, with a service user:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin heart
sudo install -m 0755 target/release/heartd /usr/local/bin/heartd
sudo install -d -m 0755 /etc/heart /etc/heart/custom /usr/local/lib/heart
sudo install -d -m 0750 -g heart /etc/heart/tls
sudo install -m 0644 heartd.toml card.json rules.json /etc/heart/
sudo install -m 0644 custom/*.json /etc/heart/custom/
sudo install -m 0644 gateway.crt /etc/heart/tls/
sudo install -m 0640 -g heart gateway.key /etc/heart/tls/
sudo install -m 0644 heart-pump-feeder.py heart-monitor-report.py /usr/local/lib/heart/
sudo heartd check /etc/heart/heartd.toml          # prints "configuration is valid"
echo "net.ipv4.ip_local_reserved_ports = 43278" | sudo tee /etc/sysctl.d/99-heart.conf
sudo sysctl --system

The TLS certificate comes from the utility's own certificate authority, for the gateway's name on the telemetry VLAN.

Run heartd as a service. Use packaging/heartd.service from heart-core-rs unchanged (Running as a service):

[Unit]
Description=HEART Core
After=network.target

[Service]
ExecStart=/usr/local/bin/heartd run /etc/heart/heartd.toml
Restart=on-failure
RestartSec=2
StartLimitIntervalSec=300
StartLimitBurst=5
User=heart
Group=heart
StateDirectory=heart
RuntimeDirectory=heart

# The Core must never take resources from the product.
MemoryMax=32M
CPUQuota=10%
Nice=10

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadWritePaths=/var/lib/heart /run/heart

[Install]
WantedBy=multi-user.target

It writes only /var/lib/heart (state and identity key) and /run/heart (the two sockets). The two helpers get units of their own. The feeder (/etc/systemd/system/heart-pump-feeder.service):

[Unit]
Description=HEART pump feeder (reads the PLC, read only)
After=heartd.service
BindsTo=heartd.service

[Service]
ExecStart=/usr/bin/python3 /usr/local/lib/heart/heart-pump-feeder.py
User=heart
Group=heart
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
MemoryMax=48M
CPUQuota=5%

[Install]
WantedBy=multi-user.target

The reporter is the same, except: ExecStart=/usr/bin/python3 /usr/local/lib/heart/heart-monitor-report.py, Environment=HEART_MONITOR_URL=https://monitor.water.example (your own site), StateDirectory=heart-monitor and WorkingDirectory=/var/lib/heart-monitor for its device ID, claim code and key, and no PrivateDevices, with SupplementaryGroups= the group that may use the relay's GPIO.

sudo systemctl daemon-reload
sudo systemctl enable --now heartd heart-pump-feeder
journalctl -u heartd -u heart-pump-feeder -f                     # the logs
curl --unix-socket /run/heart/api.sock http://localhost/heart/v1/pulse
curl --cacert utility-ca.pem https://10.20.2.20:43278/.well-known/heart-card.json   # from SCADA's side

heartd supervise adds a watchdog of its own (it needs [supervisor] heartbeat_file); systemd's Restart=on-failure is enough to start with.


5. At commissioning#

For each station:

  1. The PLC engineer adds the mirror block (Section 1), under the utility's change procedure, and checks it in the PLC's simulator. The station's own trips and interlocks are not touched.
  2. Mount the gateway on the DIN rail, on the UPS-backed 24 V supply. Port 1 to the control VLAN, port 2 to the telemetry VLAN. Turn off IP forwarding. SSH by key, from the engineering workstation only. Point its clock at the utility's time server.
  3. Set the firewall in front of the PLC (Section 1), then prove it: from the gateway, try a Modbus write (function 6) to a spare register with a test tool. It must be refused, and logged.
  4. Install heartd, its files and the two helpers (Section 4), and start heartd and the feeder.
  5. Compare every value that GET /heart/v1/properties shows (with an internal token) against the PLC's HMI or SCADA. For example, the sump level must read 2.500 m on both. A wrong scale here makes every rule wrong.
  6. Turn reporting on: sudo systemctl enable --now heart-monitor-report. Read the registration line with journalctl -u heart-monitor-report, such as (made-up values): HEART Hub: register this device at https://monitor.water.example/register?id=c27e94a1b05d3f88 with claim code T6RJ9-QW2MK-HX7CN-4BZPE.
  7. Label the inside of the panel door with the device ID, the claim code and a QR code of https://<your monitor site>/register?id=<ID>.
  8. Register the station on the utility's monitor site, signed in with the operations team's address, and share its page with the on-call operators. Write the device ID in the commissioning record. The claim code stays in the journal: keep the gateway's journal on the gateway.
  9. Run the tests of Section 7 that are safe at a live station (PLC not answering, gateway power, telemetry link, write refused). The rest belong on the test rig before installation.
  10. Tell the crew in the commissioning notes: the HEART lamp is separate from the PLC's alarms, and when the telemetry link is down it is the only route.

6. What the operators see#

  1. In the control room. SCADA reads the gateway's HEART API: the Pulse, the open alerts with their explanations, and every value, with a token. It raises its own alarm and pages the on-call operator, as for any other alarm.
  2. On the monitor site. The station's page shows Healthy, private to the team it is shared with. See the owner's manual.
  3. Every day. Nothing. The PLC runs the station; the HEART watches it.
  4. When something goes wrong:
Real eventThe HEART noticesThe operators see
The sump level transmitter freezessensors.stuck, after 90 s with no change while water movesDegraded: "do not trust LT-101". The PLC's dry-run protection still uses its own float switch.
The suction strainer blocks: flow falls, vibration risessystem.main-function-degraded (likely cavitation)Degraded, before the pump is damaged
Pump 1's cooling fan is blockedperformance.too-hot from 100 °CDegraded, and the lamp. At 120 °C the PLC trips pump 1 and starts pump 2, with or without HEART.
A valve on the rising main is shut by mistake: the PLC trips both pumps on high pressuresystem.main-function-lostFailing; SCADA pages the on-call operator. The reset is local, on purpose: someone must find the valve.
Mains is lost: the PLC and gateway run on the UPSpower.supply-lost and system.main-function-lostFailing until mains returns and the PLC restarts a pump
The telemetry router drops outconnectivity.link-lost (from the PLC's bit)Nothing reaches the control room or the site: Not heard from after 90 s (three Pulse intervals of 30 s), and one email. On site, the lamp.
The PLC stops answering the gatewayconnectivity.dependency-unreachable, within about 10 to 30 sDegraded. The station runs on; its values in HEART are old until the PLC answers again.
The gateway failsNothing can be sentNot heard from after 90 s. The station runs exactly as before.

The monitor site keeps only the status, the timing and the fault codes: never a level, a flow, a pressure or a PLC event.

  1. Changing anything. There is nothing to change through HEART. Pumps, trips and setpoints stay with the PLC and the people at the station.

7. Test it on the bench#

On a test rig before installation: the same PLC model with the station's program, the gateway, the firewall, and a loop calibrator and a resistance decade box in place of the transmitters. Cause every fault for real, and check the right fault code on GET /heart/v1/arrhythmias and on the monitor page, and that the PLC behaves exactly the same with the gateway on, off and removed.

FaultHow to cause itExpect
Level stuckHold LT-101's input at a fixed current with the calibrator while the flow input changessensors.stuck after 90 s. Lower the float switch: the PLC's dry-run stop still works.
CavitationSet the vibration input to 9 mm/s: 4 + 9 / 25 × 16 = 9.76 mA. Set the flow input to 180 m³/h (60 % of 300): 4 + 180 / 500 × 16 = 9.76 mAsystem.main-function-degraded
Motor hotReplace pump 1's PT100 with the decade box: 138.5 Ω for 100 °C, then about 146.1 Ω for 120 °Cperformance.too-hot at 100 °C; at 120 °C the PLC trips pump 1. Repeat with heartd stopped: the PLC trips the same.
High-pressure tripRaise the pressure input above the trip settingsystem.main-function-lost (critical); the PLC needs its local reset
Mains lostSwitch off mains to the rig; the UPS carries the PLC and gatewaypower.supply-lost and system.main-function-lost
Telemetry link lostUnplug the router's uplink for 3 minutesconnectivity.link-lost; Not heard from, then Healthy again; the lamp on site
PLC not answeringUnplug the gateway's port 1 cableconnectivity.dependency-unreachable; resolved by itself when plugged back
Gateway removedPull the gateway's powerThe PLC runs on unchanged; Not heard from after 90 s
A write to the PLCFrom the gateway, send function 6 and function 16 to registers 100 and 0 with a test toolRefused by the firewall (and by the PLC, if it can); nothing changes in the PLC
A write through HEARTPUT /heart/v1/properties/org.example.water.pump-station.sump_level, and PUT /heart/v1/custom/anything, with an internal tokenRefused: the property is not writable, and custom answers 501. Note the codes you get: the guide's 403 capability-not-allowed is the simulator's.

Then run the HEART-Rating client against the gateway, from the station network, and claim only what passed. Expect it to notice that alerts carry kind and not code (Section 3.6) until heartd adds it.


8. The safety boundary and standards: what to expect#

LayerIn this stationRun byHEART's role
Safety (trips, interlocks)Dry-run stop, high-pressure trip, thermal trips, emergency stopThe PLC and hardwired relaysNone. Read-only copies at most
Real-time controlStart and stop on level, duty and standby, the PLC scanThe PLCNone in the loop. It reads values
SupervisionStart and stop levels, duty rotationOperators and engineersNothing, in this design
Maintenance and diagnosisCavitation, hot motors, stuck sensorsEngineersIts main value: notices, explains, tells a person
  • Read only, three times over. heartd has only function 3 definitions and offers no way to add others (no custom or native module). The PLC serves a mirror block that controls nothing. The firewall in front of the PLC passes only reads from the gateway. Keep all three: heartd's adapter can send function 6, so the configuration alone is not enough.
  • No Stop button, by design. The standard asks for a HEART Stop control only from Autonomy Level A3. This HEART takes no action. The station's emergency stop is a hardwired safety function, not a HEART control.
  • Fail safe. Every test in Section 7 that removes the gateway must show the station unchanged. If one does not, HEART is in the wrong place.
  • HEART conformance says nothing about the safety or security of the station. The PLC program, the safety functions and the network still need their own work: for example IEC 61508 or IEC 61511 for safety functions, IEC 62443 for industrial security (the gateway sits in the control zone, with a conduit to the telemetry zone), and the utility's own regulator.

9. Going further#

  • Read the input tables directly. Add functions 2 and 4 to heartd's Modbus adapter (crates/heartd/src/adapters.rs in heart-core-rs), and the mirror block is no longer needed. Give the gateway no write function at all, and keep the firewall rule.
  • Rules on polled values. Today heartd's rules watch only values from its host feed, so the feeder sends them. If heartd fed its polled properties to its rules, the feeder could shrink to the checks across two signals (cavitation, the stuck level).
  • Autonomy Level A2 ("flow fell from 300 to 175 m³/h and vibration rose from 2.2 to 9.4 mm/s within a minute: likely a blocked strainer, confidence medium"): HEART Connected, with Rhythm events as evidence. Set the Card's level to connected.
  • A3, only in the supervision layer, such as "make pump 2 duty until pump 1's bearing is checked": HEART Full, a person's approval with a passkey ([presence]), and the PLC still accepts or refuses it with its own interlocks. That would need a write path, a new safety review, and is not part of this design. Resetting a trip stays local, at every level.
  • Many stations. Each gateway can join one HEART Gateway in the control room (heartd gateway, with [uplink] on each station), so SCADA reads one port for the whole network.
  • An AI model that explains alerts, under a brain lease from a Command Center: the [brain] section, shown commented out in the example's heartd.toml. It only explains and suggests, and the utility's own site would be the Command Center.