hart-sim
Simulated HART field devices, a pressure and a temperature transmitter, that speak HART-IP over TCP and UDP on port 5094. Develop and test HART software (control-system links, asset management, commissioning scripts, gateways) without devices, a HART modem or a commercial simulator, also in CI. The devices keep state like real ones, turn a process value into a 4-20 mA loop current, and can be made to fail on purpose while they run.
$ hart-sim serve --bench plant.yaml &
hart-sim: 2 devices on HART-IP 127.0.0.1:5094 (tcp+udp), control on :5095
$ hart-sim poll
addr 1 PT-101 pressure-transmitter id 0x000001
addr 2 TT-201 temperature-transmitter id 0x000002
$ hart-sim cmd --tag PT-101 3
loop 10.70 mA | PV 418.9 kPa | SV 23.5 degC
$ hart-sim inject PT-101 sensor_failure
$ hart-sim cmd --tag PT-101 3
status: device malfunction, more status available, PV out of limits | loop 21.00 mA | PV 416.9 kPa (bad) | SV 23.5 degC
Why
HART software is usually written against the one real device on the bench, or against an expensive simulator, and is rarely tested in CI at all. hart-sim gives you devices that:
- are reachable over HART-IP (TCP and UDP, port 5094), so anything that speaks HART-IP can talk to them, with no hardware
- keep state like a real device: tag, range, unit, damping, write protect, configuration-change counter, status bits
- behave plausibly: the process value becomes a 4-20 mA loop current with saturation and NAMUR NE 43 alarm currents
- are strict where real devices are strict: a bad checksum, a wrong address or a malformed frame gets no answer, never a lenient guess
- break on purpose: sensor failure, saturation, busy, communication errors, missing answers, drift, injected at runtime through a control channel
Install
Requires Python 3.10 or newer; the only dependency is PyYAML.
pip install hart-sim
From source, with the test dependencies:
git clone https://github.com/steitec/hart-sim.git && cd hart-sim
pip install -e ".[dev]"
pytest
Quick start
hart-sim serve pressure # one device, polling address 0
hart-sim serve --bench plant.yaml # several devices, see Bench files
plant.yaml is the two-device bench shown under Bench files;
the repository has it as
examples/plant.yaml.
The server listens on 127.0.0.1; HART-IP uses port 5094 (TCP and UDP) and
the control channel port 5095. Change them with --host, --port and
--control-port (0 picks a free port). From a second terminal:
$ hart-sim poll # which devices answer (command 0 + 13, all 64 addresses)
$ hart-sim cmd --tag PT-101 3 # command 3 to the device with this tag
$ hart-sim cmd --address 1 15 # ... or to a polling address
$ hart-sim cmd --address 1 35 0c4416000000000000 # raw request data as hex
$ hart-sim inject PT-101 drift per_second=0.5
$ hart-sim clear PT-101 drift # or: hart-sim clear PT-101 (all faults)
$ hart-sim set PT-101 pv 123.4 # override the process value; "auto" releases it
$ hart-sim profiles # bundled profiles
poll and cmd accept --host, --port and --udp; inject, clear and
set accept --host and --control-port. The decoded one-line output covers
commands 1, 2, 3, 13, 15 and 48; every other command prints the response code,
the device status and the data as hex.
Profiles
A profile describes one device type in YAML. Two are bundled:
| Profile | Device type | PV | SV |
|---|---|---|---|
pressure |
0xFA01 |
pressure, kPa, sensor limits -100 .. 2500, range 0 .. 1000 | sensor temperature, degC |
temperature |
0xFA02 |
temperature, degC, sensor limits -200 .. 850, range 0 .. 150 | terminal temperature, degC |
kind: pressure-transmitter
manufacturer_id: 0x00FA # deliberately not a real manufacturer
device_type: 0xFA01
device_revision: 1
tag: PT-101
long_tag: "Pump 1 discharge pressure"
descriptor: "SIM PRESSURE"
min_span: 10
alarm: high # alarm current: high (21.0 mA) or low (3.6 mA)
pv:
unit: kPa
sensor_limits: [-100, 2500]
range: [0, 1000] # LRV, URV
damping: 0.5 # s
source: {type: sine, offset: 400, amplitude: 50, period: 30}
sv: {name: sensor temperature, unit: degC, source: {type: constant, value: 23.5}}
Process value sources: constant (value), ramp (start, stop,
period), sine (offset, amplitude, period) and noise (sigma).
source may also be a list; the values of all its entries are summed:
source:
- {type: constant, value: 400}
- {type: sine, offset: 0, amplitude: 20, period: 30}
- {type: noise, sigma: 0.5}
Units: inH2O psi bar mbar kPa degC degF K mA %. The generic profiles use
manufacturer ID 0x00FA and expanded device types 0xFA01 / 0xFA02, which
deliberately belong to no real manufacturer.
Bench files
A bench puts several devices behind one HART-IP server. Each entry names a profile (a bundled name or a YAML file, relative to the bench file), a unique polling address (0-63) and a device ID; any other key overrides the profile:
devices:
- {profile: pressure, polling_address: 1, device_id: 0x000001, tag: PT-101}
- {profile: temperature, polling_address: 2, device_id: 0x000002, tag: TT-201}
Duplicate tags, polling addresses or device IDs are rejected with the offending entry named.
Supported commands
HART revision 7 universal and common practice commands. Write commands are refused with response code 7 in write protect mode.
| # | Name | |
|---|---|---|
| 0 | Read unique identifier | read |
| 1 | Read primary variable | read |
| 2 | Read loop current and percent of range | read |
| 3 | Read dynamic variables and loop current | read |
| 6 | Write polling address | write |
| 7 | Read loop configuration | read |
| 8 | Read dynamic variable classifications | read |
| 9 | Read device variables with status | read |
| 11 | Read unique identifier associated with tag | read |
| 12 | Read message | read |
| 13 | Read tag, descriptor, date | read |
| 14 | Read primary variable transducer information | read |
| 15 | Read device information | read |
| 16 | Read final assembly number | read |
| 17 | Write message | write |
| 18 | Write tag, descriptor, date | write |
| 19 | Write final assembly number | write |
| 20 | Read long tag | read |
| 21 | Read unique identifier associated with long tag | read |
| 22 | Write long tag | write |
| 33 | Read device variables | read |
| 34 | Write primary variable damping value | write |
| 35 | Write primary variable range values | write |
| 36 | Set primary variable upper range value | write |
| 37 | Set primary variable lower range value | write |
| 38 | Reset configuration changed flag | write |
| 40 | Enter/exit fixed current mode | write |
| 41 | Perform device self test | read |
| 42 | Perform device reset | write |
| 44 | Write primary variable units | write |
| 45 | Trim loop current zero | write |
| 46 | Trim loop current gain | write |
| 48 | Read additional device status | read |
| 59 | Write number of response preambles | write |
Commands 38 and 48 are not blocked by write protect. Unknown commands get response code 64. Requests with too little data get code 5, bad values the usual codes (2, 3, 4, 9-12, 14); a request with a bad checksum, for another address, or malformed gets no answer.
Fault injection
Faults are injected at runtime with hart-sim inject or through the
control channel. Parameters are KEY=VALUE.
| kind | parameters | effect | example |
|---|---|---|---|
no_response |
count=1 |
the next count requests get no answer |
hart-sim inject PT-101 no_response count=3 |
delay |
seconds, count (default: forever) |
answers are sent seconds late |
hart-sim inject PT-101 delay seconds=0.5 |
busy |
count=1 |
response code 32 (busy), no state change | hart-sim inject PT-101 busy count=2 |
comm_error |
flags=0x08, count=1 |
communication error byte instead of a response code | hart-sim inject PT-101 comm_error flags=0x10 |
sensor_failure |
none | device malfunction, PV out of limits and bad, alarm current, details in command 48 | hart-sim inject PT-101 sensor_failure |
saturate |
direction=high or low |
loop current pinned to 20.5 / 3.8 mA, "loop current saturated" | hart-sim inject PT-101 saturate direction=high |
cold_start |
none | cold start bit set for both masters, as after a restart | hart-sim inject PT-101 cold_start |
more_status |
bytes (6 integers) |
"more status available" until command 48 reads it; the bytes appear in its device specific status | hart-sim inject PT-101 more_status bytes=1,2,3,4,5,6 |
drift |
per_second |
the PV drifts away; clear removes it |
hart-sim inject PT-101 drift per_second=0.5 |
write_protect |
on |
write protect switched on or off | hart-sim inject PT-101 write_protect on=on |
Integers may be written in decimal or as 0x.. hex; booleans as true/false,
on/off or 1/0; bytes as a comma-separated list. The same forms work in
the JSON params of the control channel (lists and JSON booleans too).
Control channel
The bench listens on a second TCP port (default 5095) for one JSON object per
line and answers each with one line, {"ok": true} or {"ok": false, "error": ...}:
{"device": "PT-101", "inject": "sensor_failure", "params": {}}
{"device": "PT-101", "clear": true}
{"device": "PT-101", "clear": "drift"}
{"device": "PT-101", "set": {"pv": 123.4}}
{"list": true}
set with null returns the PV (or sv) to the simulated process; list
returns faults, PV, loop current and polling address per device. pv and
sv values must satisfy |value| <= 3.4e38 (what a HART float can carry).
The control channel binds 127.0.0.1 whatever --host is; use
hart-sim serve --control-host ADDRESS to listen elsewhere. A request line is
limited to 64 KiB ({"ok": false, "error": "line too long"}, then the
connection is closed).
No authentication: neither HART-IP nor the control port checks who
connects. Binding either to 0.0.0.0 (or any non-loopback address) exposes the
simulated devices to everyone who can reach the machine.
Python client
A small HART-IP master, usable on its own against real gateways too:
from hart_sim.client import HartIPClient
with HartIPClient("127.0.0.1") as client: # transport="udp" for UDP
device = client.find("PT-101") # command 11 (broadcast by tag)
print(device.read_variables()) # command 3
device.write_range(0, 600) # command 35
print(client.poll(range(8))) # devices at polling addresses 0-7
Lower level: client.command(address, number, data) returns the response frame
(response_code, device_status, data). Communication errors and missing
answers raise HartError / HartTimeout.
pytest plugin
Installing the package registers a pytest plugin with a fresh bench for every test, on free ports, stopped afterwards:
def test_range_change(hart_bench):
with hart_bench.client() as client: # connected HartIPClient
device = client.handle(0)
assert device.write_range(0, 600) == 0
def test_sensor_failure(hart_bench):
hart_bench.devices["PT-101"].faults.inject("sensor_failure")
...
hart_bench: the bench from--hart-bench FILEor the ini keyhart_bench(relative to the rootdir). Without either: onepressuredevice at polling address 0 with tagPT-101.hart_bench_factory(path_or_dict_or_profile): more benches in one test, from a bench file, a bench dict or a bundled profile name.hart_bench.client(**kwargs)returns a connectedHartIPClient(transport,timeout,primary); it is closed when the bench stops.
Limitations
- HART-IP version 1 only, unencrypted. No serial HART (no FSK), no burst mode, no device descriptions (DD), no secure connections.
- The byte layouts of the commands come from public sources, not from the paid HART specification. The simulator is not registered with FieldComm Group, uses a made-up manufacturer ID and is for development and testing only.
- The long address carries only 14 bits of the expanded device type (
0xFA01is addressed as0x3A01). - Unit code 1 is inH2O at 68 degF.
- Commands 36 and 37 apply the same limit checks as 35. A real device's command 37 keeps the span.
- Fixed 4 mA ("multidrop") is only set by command 6 (loop current mode). A polling address other than 0 in a bench file or profile does not fix the current; this deviates from the wording of the HART specification, which ties multidrop to the polling address.
- Clearing a
driftfault stops the drift but keeps the offset that has already accumulated; it does not return the PV to where it started. - The control channel and the bench (
inject,clear,set,list) address a device by its original tag, also after command 18 or 22 has renamed it. - HART-IP is IPv4 only.
- A
delayfault on UDP delays all UDP clients (single UDP thread). no_responseis consumed even by requests the device would ignore anyway.
Licence
MIT, see LICENSE.
Metadata
Release files for hart-sim 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| hart_sim-0.1.0.tar.gz | 69.0 kB | With provenance | Details for hart_sim-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| hart_sim-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for hart_sim-0.1.0-py3-none-any.whl |
Total release size: 116.4 kB
Release files / hart_sim-0.1.0.tar.gz
| Download URL | hart_sim-0.1.0.tar.gz |
|---|---|
| Size | 69.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1e5332e714f0e2d80c6f7708eaa9917d93fafd8bd952590235c26b5d2a24733d
|
|
BLAKE2b-256 checksum How to use checksums |
3a2202316ec29a79c955013fb984aeb8355dcbdbdda4707e055d7588300131c8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logRelease files / hart_sim-0.1.0-py3-none-any.whl
| Download URL | hart_sim-0.1.0-py3-none-any.whl |
|---|---|
| Size | 47.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
87254147f65ba791ade6c382cc7d1dab99b8fb4aac88b77a665709b773cf7236
|
|
BLAKE2b-256 checksum How to use checksums |
87beb28d588ecf4adf7fef75cd80e06faeb9523d0bddb09016d527a6368ee3b0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logSecurity for hart-sim 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for hart_sim-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 29, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.