HEART Embedded Systems logoHEART

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. heartd has 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.

PlacementB: 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 fromheartd, 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).
ClaimHEART Basic, Autonomy Level A1. This HEART changes nothing: it has no commands at all.
Try it firstThe 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.

PartAssumed choiceNotes
WallboxA 22 kW AC wallbox, 400 V three-phase, 32 A, with a tethered Type 2 cableCertified as a whole by its maker. Nothing on this page changes that.
Charge controllerThe wallbox's own certified microcontroller board: control pilot (CP) and proximity pilot (PP), contactor with a mirror contact, weld check, earth check, temperature deratingNever part of HEART. It keeps its own limits whatever it is told.
Leakage protectionA Type A RCD with 6 mA DC detection in the wallbox (an RDC-DD to IEC 62955), or a Type B RCD in the supplyTrips by itself; only a person resets it
Energy meterA three-phase meter on the controller's own meter busThe controller puts its register in its status messages
Connector temperatureTwo sensors in the Type 2 plug, read by the controllerThe controller derates from 32 A to 16 A above 70 °C and stops at 90 °C (the guide's illustration numbers)
Communication boardA 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 boardRuns the network, the OCPP client, the owner's app link, the LED ring, and heartd
NetworkEthernet or Wi-Fi to the home router; LTE as an optionOutbound only: OCPP to the operator's backend, reports to HEART Hub
LED ringAround the front, driven by the communication boardShows a fault to whoever stands at the charger, with no internet
Backup supplyA 5 F supercapacitor bank on the communication board's supply (see below)For one last report, power.supply-lost, after a power cut
LabelInside the cover, where the installer sees itThe 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.

LinkAssumedWhat crosses it
Controller → communication boardUART, 3.3 V, 115200 baud, the maker's own status frame once a secondConnector state, current and voltage per phase, offered current, energy register, connector temperature, RCD state, contactor command and mirror, weld flag, derating flag
Communication board → controllerThe same UARTOCPP commands from the backend (start, stop, current limit). Not HEART's. heartd has no path to this link.
Supply power-fail signal → communication boardThe power supply's AC-fail output on a GPIOTells the app the mains has gone before the supercapacitor runs down
Communication board → LED ringGPIO or I²CThe app shows a HEART fault pattern
Charger app → heartdThe host socket, /run/heart/host.sock, one JSON line per eventRead-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.
  • heartd has 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 the custom nor the native module, so none can be added through the API either (they answer 501).
  • The app owns the UART. If heartd crashes, 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#

FileWhereWhat you do
heartd/usr/local/bin/heartdBuild it (Section 4). Same binary for every charger.
heartd.toml/etc/heart/heartd.tomlThe daemon's settings (3.1)
card.json/etc/heart/card.jsonWho the charger is (3.2)
rules.json/etc/heart/rules.jsonWhat becomes an alert, with which fault code (3.4)
Host feedIn your charger appSend values and events to heartd (3.3)
heart-monitor-report/usr/local/lib/heart/heart-monitor-report.pyYou 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.

LineWhen the app sends itComponent
{"vital": "connector_temp", "value": 71.5}Every 5 sconnector_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 voltagesgrid_v_min, grid_v_max
{"event": "charger.rcd-tripped", "severity": "critical", "component": "rcd"}The controller reports an RCD triprcd
{"event": "charger.contactor-not-closing", "severity": "error", "component": "contactor"}Contactor commanded closed, mirror open, for 5 scontactor
{"event": "charger.contactor-welded", "severity": "critical", "component": "contactor"}The controller reports a weldcontactor
{"event": "charger.supply-lost", "severity": "error", "component": "supply"}The AC-fail GPIO goes lowsupply
{"event": "charger.backend-link-lost", "severity": "warning", "component": "backend"}The OCPP connection has been down for 60 sbackend
{"event": "charger.meter-stuck", "severity": "error", "component": "meter"}Current of 6 A or more flows, and the energy register has not moved for 60 smeter
{"event": "component.failed", "severity": "error", "component": "controller"}No status frame for 5 scontroller
{"recovered": "rcd"} (and so on, by component)The condition has clearedResolves 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:

RulewhenFault codeSeverity
grid-highgrid_v_max > 253power.supply-unstablewarning
contactor-open-main, contactor-open-actuatorevent charger.contactor-not-closingsystem.main-function-lost and actuators.not-respondingcritical, error
contactor-weld-main, contactor-weld-actuatorevent charger.contactor-weldedsystem.main-function-lost and actuators.not-respondingcritical, error
supply-lostevent charger.supply-lostpower.supply-losterror
backend-link-lostevent charger.backend-link-lostconnectivity.link-lostwarning
meter-stuckevent charger.meter-stucksensors.stuckerror
controller-silentevent component.failed, component controllerconnectivity.dependency-unreachableerror

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:

  1. Read GET http://127.0.0.1:43278/heart/v1/pulse and GET http://127.0.0.1:43278/heart/v1/arrhythmias (public views, no token).
  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.

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:

  1. 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. heartd loads 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.
  2. 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 heartd makes its own identity key beside its state file.
  3. 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.
  4. 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/arrhythmias with the right code. Then clear the alerts the tests raised.
  5. Run the bench tests of Section 7 on a sample from each batch.

6. What the owner and installer see#

  1. Installation. A qualified electrician fits and commissions the charger, as before. The maker's app joins it to the home network.
  2. 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.
  3. Every day. Nothing. The charger charges; the HEART watches.
  4. When something goes wrong:
Real eventThe HEART noticesThe owner and installer see
An earth leak in the car or cable trips the RCDsystem.main-function-lost, critical, within a few secondsThe 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 flowssystem.main-function-lost and actuators.not-responding, after 5 sFailing: call the installer
The contactor welds as the car leavesThe same two codes; the controller locks itself outFailing until the contactor is replaced
A worn pin heats the connector above 70 °Cperformance.too-hot, warning; the controller derates to 16 ADegraded, with "call the installer" in the explanation
A phase dips below 207 V or swells above 253 Vpower.supply-unstable, warningDegraded while it lasts
A power cutpower.supply-lost, error, sent on the supercapacitorDegraded, then Not heard from after 90 s
The OCPP backend cannot be reachedconnectivity.link-lost, warningDegraded; charging at home carries on if the charger allows it offline
The meter stops counting while current flowssensors.stuck, error, after 60 sDegraded: billing and energy records are wrong
The home router is downNothing can be sentNot 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/).

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

FaultHow to cause it on the benchExpect
RCD tripPress the RCD's test button during a sessionsystem.main-function-lost (critical). Resolved only after the reset at the charger.
Contactor will not closePower off, disconnect the contactor coil, power on, start a sessionsystem.main-function-lost and actuators.not-responding
Contactor weldedUse the controller maker's weld-check test fixture, on the rig onlyThe same two codes, and the controller's lock-out
Connector hotReplace 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 unstableFeed the charger from a programmable three-phase AC source; set one phase to 200 V, then 260 Vpower.supply-unstable
Supply lostSwitch off the supply breaker during a sessionpower.supply-lost on the page (if the backup lasts), then Not heard from
Backend link lostBlock the OCPP server's address at the router for 2 minutesconnectivity.link-lost; the page still gets reports
Meter stuckHold the meter register constant, for example with a meter simulator on the controller's meter bus, if your controller allows itsensors.stuck after 60 s
Controller silentPower off, unplug the controller–board cable, power onconnectivity.dependency-unreachable
HEART stoppedsudo systemctl stop heartd during a sessionCharging carries on unchanged; the page shows Not heard from after 90 s
Nothing can be changedcurl -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 tokenRefused, 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 backendGoes to HEART
The control pilot offer, never above the cable's ratingKnowing whether the charger can do its job
Contactor, RCD and DC leakage, earth check, weld check, deratingRaising 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 heartd as 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's properties() 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 level to connected.
  • 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 stay private, 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).