EV charger: on a real device#
A home wallbox with a HEART on its communication board, beside the certified charge controller and the OCPP client: the parts, the links, the configuration, how to build and install it, the factory steps, and what the owner and installer see.
For illustration only. The hardware below is an assumption, chosen so the steps can be concrete. Your charger, boards and links will differ. This product has not been built.
heartdhas been tested only on an x86-64 Linux virtual machine, against a simulated Modbus device and a mock model: never on an ARM communication board, never beside a real charge controller, and never with a real car. A charger is mains equipment: only a qualified person may open, wire or test it. Nothing here is electrical advice or a certification route. Check every step on your own bench.
| Placement | B: beside a controller. The HEART runs on the communication board, beside the OCPP client. The certified charge controller (contactor, RCD and DC leakage, control pilot and proximity pilot, earth check) is never touched. |
| Start from | heartd, the Rust Core's daemon, fed by the charger's own app through its host socket. For HEART Basic only, the Python template is a smaller choice (Section 9). |
| Claim | HEART Basic, Autonomy Level A1. This HEART changes nothing: it has no commands at all. |
| Try it first | The simulator and the design case study |
1. The device we assume#
The case study and the simulator take a 7.4 kW single-phase charger. This page assumes the 22 kW three-phase version of the same wallbox: 3 phases × 230 V × 32 A = 22,080 W, about 22 kW. The HEART is the same; only the voltage check covers three phases instead of one.
| Part | Assumed choice | Notes |
|---|---|---|
| Wallbox | A 22 kW AC wallbox, 400 V three-phase, 32 A, with a tethered Type 2 cable | Certified as a whole by its maker. Nothing on this page changes that. |
| Charge controller | The wallbox's own certified microcontroller board: control pilot (CP) and proximity pilot (PP), contactor with a mirror contact, weld check, earth check, temperature derating | Never part of HEART. It keeps its own limits whatever it is told. |
| Leakage protection | A Type A RCD with 6 mA DC detection in the wallbox (an RDC-DD to IEC 62955), or a Type B RCD in the supply | Trips by itself; only a person resets it |
| Energy meter | A three-phase meter on the controller's own meter bus | The controller puts its register in its status messages |
| Connector temperature | Two sensors in the Type 2 plug, read by the controller | The controller derates from 32 A to 16 A above 70 °C and stops at 90 °C (the guide's illustration numbers) |
| Communication board | A Linux module, such as a Raspberry Pi Compute Module 4 (2 GB RAM, 16 GB eMMC) or an NXP i.MX 8M Mini module, on the wallbox's carrier board | Runs the network, the OCPP client, the owner's app link, the LED ring, and heartd |
| Network | Ethernet or Wi-Fi to the home router; LTE as an option | Outbound only: OCPP to the operator's backend, reports to HEART Hub |
| LED ring | Around the front, driven by the communication board | Shows a fault to whoever stands at the charger, with no internet |
| Backup supply | A 5 F supercapacitor bank on the communication board's supply (see below) | For one last report, power.supply-lost, after a power cut |
| Label | Inside the cover, where the installer sees it | The device ID, the claim code and a QR code of the registration address |
No HEART button is needed: this HEART takes no action, so it has nothing to approve and no Stop control (the guide's Step 6). The charger's own emergency stop and the backend's remote stop are not HEART's.
Sizing the backup (an estimate, check yours). Assume the board and modem draw 3 W for the 10 s it takes to send one report: 3 W × 10 s = 30 J. Two 10 F, 2.7 V cells in series give 5 F at 5.4 V. Used from 5.0 V down to 2.5 V through a boost converter that is 85 % efficient, they hold ½ × 5 F × (5.0² − 2.5²) V² × 0.85 = ½ × 5 × 18.75 × 0.85 ≈ 40 J. That is enough, with a little margin. Without a backup, a power cut shows on HEART Hub as Not heard from instead.
Example links inside the wallbox#
| Link | Assumed | What crosses it |
|---|---|---|
| Controller → communication board | UART, 3.3 V, 115200 baud, the maker's own status frame once a second | Connector state, current and voltage per phase, offered current, energy register, connector temperature, RCD state, contactor command and mirror, weld flag, derating flag |
| Communication board → controller | The same UART | OCPP commands from the backend (start, stop, current limit). Not HEART's. heartd has no path to this link. |
| Supply power-fail signal → communication board | The power supply's AC-fail output on a GPIO | Tells the app the mains has gone before the supercapacitor runs down |
| Communication board → LED ring | GPIO or I²C | The app shows a HEART fault pattern |
Charger app → heartd | The host socket, /run/heart/host.sock, one JSON line per event | Read-only by design: values go in, nothing comes back but ok |
The controller never talks to heartd. The charger app on the communication board, which already reads the status frame for OCPP, passes copies of the values to heartd. That is the read-only link.
2. Where everything runs#
mains ─► RCD ─ contactor ─ meter ─► cable ─► car
▲ ▲ │
└── charge controller (certified, real time) ──status frame──┐
▼
┌──────────────── communication board (Linux) ─────────────────────────────────┐
│ charger app: reads the status frame ──► OCPP client ──────────────────────────────► operator's backend
│ │ copies of values (host feed, one way) │
│ ▼ │
│ heartd: rules, alerts, HEART API on 127.0.0.1:43278 ◄── LED ring status │
│ ▲ public Pulse and alerts │
│ heart-monitor-report ─────────────────────────────────────────────────────────────► HEART Hub
└───────────────────────────────────────────────────────────────────────────────┘ (HTTPS, outbound only)
- HEART does not implement OCPP and does not replace it. OCPP runs the charging business: sessions, authorisation, tariffs, remote start and stop. HEART says whether the charger is well. They sit side by side, and HEART reads the same status the charger already sends over OCPP.
heartdhas no way to reach the controller. It loads no custom definitions, so it has no native adapter, no command and no capability. The Card offers neither thecustomnor thenativemodule, so none can be added through the API either (they answer501).- The app owns the UART. If
heartdcrashes, is stopped or is hacked, the app and the controller go on charging exactly as before (Part 6, Section 9, rules 1, 3 and 6). - Card and vehicle IDs never reach HEART. The app sends only the values in Section 3.3. RFID card IDs, the car's ISO 15118 identity and account numbers stay in the controller and the OCPP backend.
3. The configuration#
| File | Where | What you do |
|---|---|---|
heartd | /usr/local/bin/heartd | Build it (Section 4). Same binary for every charger. |
heartd.toml | /etc/heart/heartd.toml | The daemon's settings (3.1) |
card.json | /etc/heart/card.json | Who the charger is (3.2) |
rules.json | /etc/heart/rules.json | What becomes an alert, with which fault code (3.4) |
| Host feed | In your charger app | Send values and events to heartd (3.3) |
heart-monitor-report | /usr/local/lib/heart/heart-monitor-report.py | You write it, from the Python template (3.5). heartd does not report to HEART Hub by itself. |
Relative paths in heartd.toml are read from the file's own folder, and unknown keys are refused (CONFIGURATION.md).
3.1 heartd.toml#
[system]
card = "card.json"
instance = "wallbox" # a label only: never the household, never the address
rules = "rules.json"
# no custom_dir: this HEART has no device mappings and no commands
[server]
bind = "127.0.0.1" # only the board itself; anything else needs tls_cert and tls_key
host_socket = "/run/heart/host.sock" # the charger app's feed, mode 0600
[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 = 900 # an error or critical alert still open after 15 min is marked escalated
escalate_to = "installer" # a label recorded on the alert
Leave out [model], [brain], [presence] and [uplink]: no AI model, nothing to approve, no Gateway. Add [auth] (issuer, public_key) when the maker's app needs the internal or private views: heartd accepts Ed25519 JWTs from your own authorisation server.
3.2 card.json#
{ "system": { "id": "com.example.wallbox.home-22kw", "name": "Example home charger 22 kW", "version": "1.0.0" },
"level": "basic", "bindings": ["http-server"], "pulse": { "intervalSec": 30 },
"modules": [], "shares": { "pulse": "public", "arrhythmia": "public" },
"autonomy": { "level": 1 }, "capabilities": [] }
The system ID names the product, never the household. The charger's OCPP identity (its charge point ID) belongs to the backend and is not part of HEART. Validate both files with heartd check (Section 4).
3.3 The host feed (your charger app)#
Each time the app reads a status frame, it sends a few lines to heartd's host socket. The host feed takes one JSON object per line and answers ok, or error with a reason.
| Line | When the app sends it | Component |
|---|---|---|
{"vital": "connector_temp", "value": 71.5} | Every 5 s | connector_temp |
{"vital": "grid_v_min", "value": 228.4}, {"vital": "grid_v_max", "value": 233.1} | Every 5 s: the lowest and highest of the three phase voltages | grid_v_min, grid_v_max |
{"event": "charger.rcd-tripped", "severity": "critical", "component": "rcd"} | The controller reports an RCD trip | rcd |
{"event": "charger.contactor-not-closing", "severity": "error", "component": "contactor"} | Contactor commanded closed, mirror open, for 5 s | contactor |
{"event": "charger.contactor-welded", "severity": "critical", "component": "contactor"} | The controller reports a weld | contactor |
{"event": "charger.supply-lost", "severity": "error", "component": "supply"} | The AC-fail GPIO goes low | supply |
{"event": "charger.backend-link-lost", "severity": "warning", "component": "backend"} | The OCPP connection has been down for 60 s | backend |
{"event": "charger.meter-stuck", "severity": "error", "component": "meter"} | Current of 6 A or more flows, and the energy register has not moved for 60 s | meter |
{"event": "component.failed", "severity": "error", "component": "controller"} | No status frame for 5 s | controller |
{"recovered": "rcd"} (and so on, by component) | The condition has cleared | Resolves every open alert of that component |
import json, socket
def heart_feed(line):
"""CHANGE: call from the charger app. 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)
Three things to know about how heartd handles these:
- A rule that watches a vital fires on any single sample. So that one odd reading is not a fault (the guide asks for 5 s), the app sends each vital as the value that held for the whole 5 s: the lowest reading in the window for an upper limit (
connector_temp,grid_v_max), and the highest for a lower limit (grid_v_min). - A rule compares one signal with one number. A check across two signals, such as "current flows but the meter does not move", is done in the app, which sends
charger.meter-stuck. - Alerts close when the app says so. Send
{"recovered": "connector_temp"}when the connector is back below 65 °C (an assumed 5 °C hysteresis), and so on. An alert raised by a vital rule takes the vital's name as its component.
The socket is mode 0600, so only the user that runs heartd may write to it: run heartd as the charger app's own user (Section 4).
3.4 rules.json#
Each line of the guide's fault table becomes one or two Tending rules. Writing "status": "adopted" in this file is the adoption: here by the maker ("origin": "vendor"), with adoptedAt as the rule schema asks. The rules only raise alerts: none has a capability.
[
{ "id": "rcd-tripped", "enabled": true, "status": "adopted", "origin": "vendor", "adoptedAt": "2026-11-12T09:00:00Z",
"description": "The RCD has tripped",
"when": { "event": "charger.rcd-tripped" },
"then": { "raise": { "code": "system.main-function-lost", "severity": "critical",
"summary": "The RCD has tripped. A person must inspect the car and cable and reset it at the charger." } } },
{ "id": "connector-hot", "enabled": true, "status": "adopted", "origin": "vendor", "adoptedAt": "2026-11-12T09:00:00Z",
"description": "Connector above 70 Cel: the controller derates",
"when": { "signal": "connector_temp", "op": ">", "value": 70 },
"then": { "raise": { "code": "performance.too-hot", "severity": "warning",
"summary": "The connector is above 70 °C and the charger has reduced the current. A worn pin is likely: call the installer." } } },
{ "id": "grid-low", "enabled": true, "status": "adopted", "origin": "vendor", "adoptedAt": "2026-11-12T09:00:00Z",
"description": "A phase below 207 V",
"when": { "signal": "grid_v_min", "op": "<", "value": 207 },
"then": { "raise": { "code": "power.supply-unstable", "severity": "warning",
"summary": "The supply voltage is below 207 V on at least one phase." } } }
]
The rest follow the same pattern:
| Rule | when | Fault code | Severity |
|---|---|---|---|
grid-high | grid_v_max > 253 | power.supply-unstable | warning |
contactor-open-main, contactor-open-actuator | event charger.contactor-not-closing | system.main-function-lost and actuators.not-responding | critical, error |
contactor-weld-main, contactor-weld-actuator | event charger.contactor-welded | system.main-function-lost and actuators.not-responding | critical, error |
supply-lost | event charger.supply-lost | power.supply-lost | error |
backend-link-lost | event charger.backend-link-lost | connectivity.link-lost | warning |
meter-stuck | event charger.meter-stuck | sensors.stuck | error |
controller-silent | event component.failed, component controller | connectivity.dependency-unreachable | error |
The voltage window is the usual ±10 % around 230 V: 230 × 0.9 = 207 V and 230 × 1.1 = 253 V. The last rule is not in the guide: a real board should add it, because the app can lose its controller. Every code is in Part 3, Section 5.4. Take the real thresholds from your charger's own specification.
The Pulse follows the worst open alert: critical makes it failing; error or warning make it degraded. That matches the guide.
3.5 Reporting to HEART Hub#
heartd has no HEART Hub setting: there is no such section in CONFIGURATION.md. Until it has one, a small helper does it. 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:
- Read
GET http://127.0.0.1:43278/heart/v1/pulseandGET http://127.0.0.1:43278/heart/v1/arrhythmias(public views, no token). - Copy each alert's
kindintocode.heartddoes not yet put the Part 3codeon its alerts (CONFORMANCE.mdlists it as not implemented); the rule'sraise.codeends up inkind. A monitor site refuses an alert withoutcode. - Send at most 16 alerts, newest first, as in HEART Hub, Section 5. If
heartddoes not answer, send nothing, so the page shows Not heard from.
The helper makes the device ID, claim code and key once, keeps them in a file of mode 0600, and prints one line at first start, as the template does:
HEART Hub: register this device at https://heartcortex.ai/register?id=8d41b7e2a95c3f06 with claim code M7Q2K-XR4TB-9WNHC-3PZDA
4. Build and install#
On a real charger, these files are part of the communication board's signed firmware image, not copied by hand. The steps below are what the image build does, and what you do on a development board.
Build heartd for the board. For example, on an arm64 Linux machine (or with a cross-compiler for aarch64-unknown-linux-gnu, which also needs a C cross-compiler for the ring crate):
cd heart-core-rs
cargo build --release -p heartd
The README measures the binary at 2.9 MB on x86-64. It has not been built for ARM in this project yet.
Install it, with the configuration:
sudo install -m 0755 target/release/heartd /usr/local/bin/heartd
sudo install -d -m 0755 /etc/heart
sudo install -m 0644 heartd.toml card.json rules.json /etc/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
Run it as a service. Start from packaging/heartd.service in heart-core-rs (Running as a service). It runs /usr/local/bin/heartd run /etc/heart/heartd.toml, limits it to 32 MB of memory and 10 % of one CPU at low priority, so the HEART never takes resources from the OCPP client, and lets it write only /var/lib/heart and /run/heart. It runs as user heart; change that to the charger app's own user (here assumed to be wallbox), so that the app may write to the host socket:
sudo install -m 0644 packaging/heartd.service /etc/systemd/system/heartd.service
sudo systemctl edit heartd # add the three lines below
[Service]
User=wallbox
Group=wallbox
And the Monitor helper, as its own unit (/etc/systemd/system/heart-monitor-report.service):
[Unit]
Description=HEART Hub reports (public Pulse and alerts only)
After=heartd.service network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /usr/local/lib/heart/heart-monitor-report.py
Environment=HEART_MONITOR_URL=https://heartcortex.ai
WorkingDirectory=/var/lib/heart-monitor
StateDirectory=heart-monitor
User=wallbox
Group=wallbox
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
MemoryMax=48M
CPUQuota=5%
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now heartd
journalctl -u heartd -f # the daemon's log
curl -s http://127.0.0.1:43278/.well-known/heart-card.json
curl -s http://127.0.0.1:43278/heart/v1/pulse
Reporting is off until someone turns it on: sudo systemctl enable --now heart-monitor-report, at the factory in the image, or by the installer with the owner's agreement. Then journalctl -u heart-monitor-report shows the registration line.
Network. Nothing reaches the board from outside. Outbound, the board needs only: the OCPP backend (as before), HTTPS on port 443 to the one monitor site, network time (NTP), and the maker's update server. The HEART API stays on 127.0.0.1.
5. On the production line#
For each charger:
- Flash the release image of the communication board, with secure boot on, so the configuration and rules cannot be changed without the maker's signature.
heartdloads its rules from local files and does not yet check signatures on them (CONFORMANCE.md, Core 9.15): the signed image is what protects them. - Start it once on the end-of-line test rig, with the Monitor helper on. It makes its device ID, claim code and key, and
heartdmakes its own identity key beside its state file. - Read the device ID and claim code from the rig's console, and print the label for inside the cover: the claim code, and a QR code of
https://heartcortex.ai/register?id=<ID>. Replace the helper's console line with your rig's label printer. Store the same pair in the installer's commissioning app if you have one. - Run the end-of-line tests the charger already has (RCD test, earth check, insulation, a session on an EV test adapter), and check that each one shows up in
GET /heart/v1/arrhythmiaswith the right code. Then clear the alerts the tests raised. - Run the bench tests of Section 7 on a sample from each batch.
6. What the owner and installer see#
- Installation. A qualified electrician fits and commissions the charger, as before. The maker's app joins it to the home network.
- Registration. The installer reads the label inside the cover (or the commissioning app) and gives the owner the claim code. The owner scans the QR code, signs in at heartcortex.ai with an emailed code, and types the claim code. The page shows Healthy. It is private until the owner shares it; sharing it with the installer is the usual reason. See the owner's manual.
- Every day. Nothing. The charger charges; the HEART watches.
- When something goes wrong:
| Real event | The HEART notices | The owner and installer see |
|---|---|---|
| An earth leak in the car or cable trips the RCD | system.main-function-lost, critical, within a few seconds | The LED ring shows a fault; the page shows Failing. Only a person at the charger can reset it, after looking. |
| The contactor's coil burns out: a car waits, no current flows | system.main-function-lost and actuators.not-responding, after 5 s | Failing: call the installer |
| The contactor welds as the car leaves | The same two codes; the controller locks itself out | Failing until the contactor is replaced |
| A worn pin heats the connector above 70 °C | performance.too-hot, warning; the controller derates to 16 A | Degraded, with "call the installer" in the explanation |
| A phase dips below 207 V or swells above 253 V | power.supply-unstable, warning | Degraded while it lasts |
| A power cut | power.supply-lost, error, sent on the supercapacitor | Degraded, then Not heard from after 90 s |
| The OCPP backend cannot be reached | connectivity.link-lost, warning | Degraded; charging at home carries on if the charger allows it offline |
| The meter stops counting while current flows | sensors.stuck, error, after 60 s | Degraded: billing and energy records are wrong |
| The home router is down | Nothing can be sent | Not heard from after 90 s (three Pulse intervals of 30 s), and one email; the LED ring still shows any fault |
The site keeps only the status, the timing and the fault codes: never a current, an energy, a connector state or a session. Alert times can still hint at when a car was there (an RCD trips while a car charges): see the guide's Step 8. A maker with many chargers should run its own monitor site (the code is open, in heart-monitor/).
- Changing anything. There is nothing to change through HEART. Current, start and stop stay with the controller, the backend and the owner's app.
7. Test it on the bench#
On an isolated test rig, by a qualified person, with an EV test adapter (control pilot states and a proximity resistor) and a load. Cause every fault for real, and check the right fault code appears on GET /heart/v1/arrhythmias and on the HEART Hub page, and that nothing HEART does changes charging.
| Fault | How to cause it on the bench | Expect |
|---|---|---|
| RCD trip | Press the RCD's test button during a session | system.main-function-lost (critical). Resolved only after the reset at the charger. |
| Contactor will not close | Power off, disconnect the contactor coil, power on, start a session | system.main-function-lost and actuators.not-responding |
| Contactor welded | Use the controller maker's weld-check test fixture, on the rig only | The same two codes, and the controller's lock-out |
| Connector hot | Replace a connector sensor with a resistance decade box set to the value for 75 °C (from the sensor's table) | performance.too-hot; the adapter shows the current fall from 32 A to 16 A |
| Supply unstable | Feed the charger from a programmable three-phase AC source; set one phase to 200 V, then 260 V | power.supply-unstable |
| Supply lost | Switch off the supply breaker during a session | power.supply-lost on the page (if the backup lasts), then Not heard from |
| Backend link lost | Block the OCPP server's address at the router for 2 minutes | connectivity.link-lost; the page still gets reports |
| Meter stuck | Hold the meter register constant, for example with a meter simulator on the controller's meter bus, if your controller allows it | sensors.stuck after 60 s |
| Controller silent | Power off, unplug the controller–board cable, power on | connectivity.dependency-unreachable |
| HEART stopped | sudo systemctl stop heartd during a session | Charging carries on unchanged; the page shows Not heard from after 90 s |
| Nothing can be changed | curl -X PUT http://127.0.0.1:43278/heart/v1/properties/anything and curl -X POST http://127.0.0.1:43278/heart/v1/capabilities/anything/invoke, with and without a token | Refused, and nothing reaches the controller. Note the codes you get: the guide's 403 capability-not-allowed is the simulator's. |
Then run the HEART-Rating client against the charger, from the same network, and claim only what passed. Expect it to notice that alerts carry kind and not code (Section 3.5) until heartd adds it.
8. The safety boundary and standards: what to expect#
| Stays with the certified controller and the backend | Goes to HEART |
|---|---|
| The control pilot offer, never above the cable's rating | Knowing whether the charger can do its job |
| Contactor, RCD and DC leakage, earth check, weld check, derating | Raising alerts with standard fault codes |
| Start, stop, authorisation, smart charging, tariffs (OCPP) | Telling the owner and the installer, even with no internet |
- HEART reads safety signals; it never writes them. The RCD state and the weld flag reach
heartdas copies, through a socket that carries values one way. There is no path back. - Two things are never automatic, at any Autonomy Level, through HEART or anything else: current above the cable's rating (or the circuit's breaker), and re-closing after an RCD trip or a weld lock-out (the guide's Step 6).
- The device has the last word. Even the board's own OCPP client cannot make the controller exceed its limits. That is the controller's job, and why it is certified.
- HEART on the same board is still a risk to manage. It shares the board with the OCPP client. The service unit's memory and CPU ceilings keep it from starving the client, and the test "HEART stopped" in Section 7 shows the charger does not depend on it.
- HEART conformance says nothing about charger approval. A real charger still needs its own: for example IEC 61851 for conductive charging, IEC 62955 or an RCD to IEC 61008/62423 for DC leakage protection, IEC 62196 for the connector, UL 2594 where it applies, the meter rules of each market, and the local wiring rules.
9. Going further#
- The Python template instead of
heartd. For HEART Basic only, the Python template is smaller, reports to HEART Hub by itself (MONITOR = True), and can serve the guide's privacy table as properties, as the simulator'sproperties()does.heartd's host feed carries vitals and events for rules, but does not serve them as properties. - Autonomy Level A2 ("the connector reached 78 °C at 32 A on a 12 °C evening, 20 °C hotter than last month"): HEART Connected, with Rhythm events as evidence. Set the Card's
leveltoconnected. - A3 and up only for the communication board itself, never for current, start or stop: for example "restart the Wi-Fi module?" after a link loss. That needs approval with a passkey (
[presence]) and a Stop control. See the guide's Step 10. - A public site with two connectors uses the same HEART. Connector state could then be
public; sessions stayprivate, and card and vehicle IDs stay out of HEART. - Many chargers at one site can each push to one HEART Gateway (
heartd gateway, with[uplink]on each charger).