# 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.

| | |
|---|---|
| **Placement** | B: 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 from** | [`heartd`](/heart-core-rs/README.md), the Rust Core's daemon, with its Modbus TCP adapter. `heart-core-rs/examples/pump-station` is the pattern. |
| **Claim** | HEART Basic, Autonomy Level A1. **This HEART changes nothing**: it has no commands at all. |
| **Try it first** | [The simulator](/pumpStationSimulator/README.md), the [design case study](/guides/Water-Pump-Station.md), and `heartd`'s [quick start](/heart-core-rs/README.md#quick-start) with its simulated pump |

---

## 1. The station we assume

| Part | Assumed choice | Notes |
|---|---|---|
| PLC | A compact PLC with a built-in Modbus TCP server and analogue input cards, such as a Siemens S7-1200 or a Schneider Modicon M241 | Already there. Runs the pumps, duty and standby, and every trip. **Never part of HEART.** |
| Sump level | A hydrostatic level transmitter, 4–20 mA for 0–5 m (LT-101, as in the simulator), into the PLC | HEART watches it for sticking |
| Low-level float switch | Hardwired into the PLC's dry-run input and the motor starters | The dry-run protection. HEART sees it only as a copied bit. |
| Reservoir level | Reaches the PLC from the reservoir over the existing telemetry or a cable | |
| Flow | An electromagnetic flowmeter on the rising main, 4–20 mA for 0–500 m³/h | |
| Discharge pressure | A pressure transmitter, 4–20 mA for 0–10 bar | The PLC's high-pressure trip uses it, with a local reset |
| Motor windings | A PT100 in each motor winding, into an RTD card on the PLC (or a motor protection relay) | The PLC trips at 120 °C |
| Vibration | A 4–20 mA vibration transmitter (RMS velocity, 0–25 mm/s) on each pump's bearing housing | |
| Mains and UPS | A voltage transducer into the PLC; a 24 V DC UPS whose "on battery" and charge outputs go to the PLC | The PLC and the gateway both run from the UPS |
| Edge gateway | A 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 Connect | Runs `heartd` and two small helpers |
| Router | The existing telemetry router (fibre or cellular), with its firewall | Carries reports to the control room and the monitor site |
| Alarm lamp | A separate "HEART" lamp on the panel door, on the gateway's relay output | Tells 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](/pumpStationSimulator/README.md#5-the-local-heart-api)) 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](/guides/Water-Pump-Station.md#step-1-decide-where-heart-runs)): 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 from | Scale, unit |
|---|---|---|
| 100 | Input register 0: sump level | 0.001 m |
| 101 | Input register 1: reservoir level | 0.001 m |
| 102 | Input register 2: station flow | 0.1 m³/h |
| 103 | Input register 3: discharge pressure | 0.01 bar |
| 104, 105 | Input registers 4, 5: pump 1, pump 2 motor current | 0.1 A |
| 106, 107 | Input registers 6, 7: pump 1, pump 2 winding temperature | 0.1 Cel |
| 108, 109 | Input registers 8, 9: pump 1, pump 2 vibration | 0.01 mm/s |
| 110 to 113 | Input registers 10 to 13: run hours (32 bits each, high word first) | 0.1 h |
| 114 | Input register 14: mains voltage | 0.1 V |
| 115 | Input 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 |
| 116 | Input register 16: outflow to the town | 0.1 m³/h |
| 117 | Input register 17: UPS charge | 1 % |
| 118 | Discrete inputs 0 to 4, as bits 0 to 4: pump 1 running, pump 2 running, float switch low, UPS on battery, telemetry link up | bits |

Addresses are 0-based protocol addresses, as the [Signal Tables](/spec/HEART-Signal-Tables-1.0.md#where-signals-can-come-from) 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.10 | PLC, on the station control VLAN 10.20.1.0/24 |
| 10.20.1.20 | Gateway port 1, on the control VLAN |
| 10.20.2.20 | Gateway port 2, on the telemetry VLAN 10.20.2.0/24, towards the router |

| Flow | Allowed |
|---|---|
| Gateway 10.20.1.20 → PLC 10.20.1.10, TCP 502 | Yes. 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 above | No |
| PLC → gateway | No new connections |
| Control room SCADA → gateway 10.20.2.20, TCP 43278 | Yes: the HEART API, over TLS |
| Gateway → the monitor site, TCP 443 | Yes: HTTPS reports, outbound, to that one host |
| Gateway → the utility's time server, UDP 123 | Yes: signed requests and timestamps need a good clock |
| Engineering workstation → gateway, SSH | Yes, from that workstation only |
| Everything else | No |

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

```text
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](/spec/HEART-Part-6-Human-Control-and-Safety-1.0.md#9-physical-safety), 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

| File | Where | What you do |
|---|---|---|
| `heartd` | `/usr/local/bin/heartd` | Build it (Section 4) |
| `heartd.toml` | `/etc/heart/heartd.toml` | The daemon's settings (3.1) |
| `card.json` | `/etc/heart/card.json` | Who the station is (3.2) |
| `custom/*.json` | `/etc/heart/custom/` | One read-only property definition per register (3.3) |
| `rules.json` | `/etc/heart/rules.json` | What 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`](/heart-core-rs/docs/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`

```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`

```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`:

```text
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
```

```sh
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:

```json
{
  "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](/spec/HEART-API-Standard-1.0.md#14-custom-requests).

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](/heart-core-rs/docs/CONFIGURATION.md#host-feed): one JSON line per value or event.

```python
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 sends | When | Component |
|---|---|---|
| Event `station.cannot-pump` | No pump available (tripped, locked out, dry-run, no mains), or the PLC calls (bit 7) and nothing runs | `station` |
| Event `station.cavitation` | A 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³/h | `p1-cavitation`, `p2-cavitation` |
| Event `station.level-stuck` | The sump level register has not changed for 90 s while flow is above 10 m³/h | `lt-101` |
| Vitals `p1_winding`, `p2_winding` | Every 5 s, the lowest reading of those 5 s, so one odd reading is not a fault | the vital's name |
| Event `station.mains-lost` | Status bit 4, or "UPS on battery" | `mains` |
| Event `station.link-lost` | Status bit 5, or "telemetry link up" is 0 | `telemetry` |
| `{"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](/guides/Water-Pump-Station.md#step-5-the-faults-it-recognises) becomes a [Tending rule](/heart-core-rs/docs/CONFIGURATION.md#tending-rules). The utility adopts them by writing `"status": "adopted"` (with `adoptedAt`, as the rule schema asks). They only raise alerts: none has a `capability`.

```json
[
  { "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." } } }
]
```

| Rule | `when` | Fault code | Severity |
|---|---|---|---|
| `cannot-pump` | event `station.cannot-pump` | `system.main-function-lost` | critical |
| `cavitation` | event `station.cavitation` | `system.main-function-degraded` | warning |
| `level-stuck` | event `station.level-stuck` | `sensors.stuck` | warning |
| `p1-hot`, `p2-hot` | `p1_winding`, `p2_winding` `>=` 100 | `performance.too-hot` | warning |
| `mains-lost` | event `station.mains-lost` | `power.supply-lost` | error |
| `link-lost` | event `station.link-lost` | `connectivity.link-lost` | warning |
| `plc-silent` | `heartd`'s own event `component.failed` for the PLC's target, 3 times in 30 s | `connectivity.dependency-unreachable` | error |

Every code is in Part 3, [Section 5.4](/spec/HEART-Part-3-Information-Model-1.0.md#54-the-standard-fault-codes). `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`](/heart-core-rs/docs/CONFIGURATION.md). Write `heart-monitor-report.py` from the Python template's HEART Hub code ([templates README](/heart-templates/README.md#watching-devices-from-anywhere-heart-monitor)): 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`](/heart-core-rs/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](/spec/HEART-Monitor-1.0.md#5-reporting-the-device). 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):

```sh
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](/heart-core-rs/README.md#quick-start).

**Install** it on the gateway, with a service user:

```sh
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](/heart-core-rs/docs/CONFIGURATION.md#running-as-a-service)):

```ini
[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`):

```ini
[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.

```sh
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](../HEART-Monitor-Owner-Manual.md).
3. **Every day.** Nothing. The PLC runs the station; the HEART watches it.
4. **When something goes wrong:**

| Real event | The HEART notices | The operators see |
|---|---|---|
| The sump level transmitter freezes | `sensors.stuck`, after 90 s with no change while water moves | **Degraded**: "do not trust LT-101". The PLC's dry-run protection still uses its own float switch. |
| The suction strainer blocks: flow falls, vibration rises | `system.main-function-degraded` (likely cavitation) | **Degraded**, before the pump is damaged |
| Pump 1's cooling fan is blocked | `performance.too-hot` from 100 °C | **Degraded**, 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 pressure | `system.main-function-lost` | **Failing**; 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 UPS | `power.supply-lost` and `system.main-function-lost` | **Failing** until mains returns and the PLC restarts a pump |
| The telemetry router drops out | `connectivity.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 gateway | `connectivity.dependency-unreachable`, within about 10 to 30 s | **Degraded**. The station runs on; its values in HEART are old until the PLC answers again. |
| The gateway fails | Nothing can be sent | **Not 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.

5. **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**.

| Fault | How to cause it | Expect |
|---|---|---|
| Level stuck | Hold LT-101's input at a fixed current with the calibrator while the flow input changes | `sensors.stuck` after 90 s. Lower the float switch: the PLC's dry-run stop still works. |
| Cavitation | Set 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 mA | `system.main-function-degraded` |
| Motor hot | Replace pump 1's PT100 with the decade box: 138.5 Ω for 100 °C, then about 146.1 Ω for 120 °C | `performance.too-hot` at 100 °C; at 120 °C the PLC trips pump 1. Repeat with `heartd` stopped: the PLC trips the same. |
| High-pressure trip | Raise the pressure input above the trip setting | `system.main-function-lost` (critical); the PLC needs its local reset |
| Mains lost | Switch off mains to the rig; the UPS carries the PLC and gateway | `power.supply-lost` and `system.main-function-lost` |
| Telemetry link lost | Unplug the router's uplink for 3 minutes | `connectivity.link-lost`; **Not heard from**, then **Healthy** again; the lamp on site |
| PLC not answering | Unplug the gateway's port 1 cable | `connectivity.dependency-unreachable`; resolved by itself when plugged back |
| Gateway removed | Pull the gateway's power | The PLC runs on unchanged; **Not heard from** after 90 s |
| A write to the PLC | From the gateway, send function 6 and function 16 to registers 100 and 0 with a test tool | Refused by the firewall (and by the PLC, if it can); nothing changes in the PLC |
| A write through HEART | `PUT /heart/v1/properties/org.example.water.pump-station.sump_level`, and `PUT /heart/v1/custom/anything`, with an `internal` token | Refused: 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](/heart-rating/README.md) 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

| Layer | In this station | Run by | HEART's role |
|---|---|---|---|
| Safety (trips, interlocks) | Dry-run stop, high-pressure trip, thermal trips, emergency stop | The PLC and hardwired relays | **None.** Read-only copies at most |
| Real-time control | Start and stop on level, duty and standby, the PLC scan | The PLC | **None in the loop.** It reads values |
| Supervision | Start and stop levels, duty rotation | Operators and engineers | Nothing, in this design |
| Maintenance and diagnosis | Cavitation, hot motors, stuck sensors | Engineers | **Its 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](/heart-core-rs/README.md) 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.
