Skip to main content

hilspy

HILS (hardware-in-the-loop simulation) I/O for Python on a Rust core, built to be embedded in a scenario library (such as autoware_carla_scenario).

One call opens a channel: a transport (how packets travel) composed with a codec (what they mean). Both are pluggable, in Rust or in Python.

import hilspy

with hilspy.open("can://vcan0", "dbc:vehicle.dbc") as ecu:
    ecu.send("VehicleSpeed", {"Speed": 12.5, "Accel": 0.0, "Gear": "D", "Counter": 0})
    for msg in ecu:  # until ecu.close()
        print(msg.name, msg.fields)  # "SteeringCmd" {"SteeringAngle": -3.5, ...}
Transport URL
can://vcan0 Linux SocketCAN (?fd, ?echo, ?filter=0x100:0x700,...)
udp://host:port UDP to a peer (?bind=addr:port); udp://addr:port?listen replies to the last sender
tcp://host:port TCP client; tcp://addr:port?listen serves every connected client
mem://name in-process bus (tests, simulated ECUs in one process)
file://path replay a recording; ?write / ?append records one
file://drive.blf, drive.asc, drive.log?format=candump CAN logs (Vector BLF/ASC, candump) with identifiers and times; ?realtime replays them as recorded, ?write records
serial:///dev/ttyUSB0 serial port (?baud=115200, data_bits, parity, stop_bits, flow); serial://COM3 on Windows
Codec
raw (default) none: messages carry data (and id for CAN)
dbc:FILE CAN frames <-> DBC messages, via can-dbc (options strict, scaling, decode_choices, e2e)
sbf Septentrio Binary Format stream <-> blocks, via libsbf; encodes PVTGeodetic and AttEuler
nmea NMEA 0183 sentences <-> messages via nmea (GGA, RMC, GSV, ...); encodes GGA, RMC, VTG, HDT, ZDA
ubx u-blox UBX frames <-> messages via ublox (NAV-PVT, NAV-ATT, MON-VER, ...); encodes NAV-PVT
rtcm RTCM 3 corrections <-> messages via rtcm-rs (1005, 1077, ...), both ways for every message it knows
lidar:MODEL LiDAR UDP data packets <-> point clouds: Hesai and Velodyne line-ups, described by profiles

Install

Python 3.10+. One abi3 wheel per platform serves every Python version; wheels are built for Linux (x86_64, aarch64), macOS (arm64, x86_64) and Windows x64. can:// (SocketCAN) is Linux-only; every other transport and codec works everywhere.

pip install hilspy
uv add hilspy
pip install git+https://github.com/hakuturu583/hilspy   # from source (needs Rust)

From a checkout:

uv sync                             # builds the extension with maturin
uv run pytest

Versions follow semver and are released by labelling the PR (bump patch / bump minor / bump major); see docs/releasing.md.

API

The whole public API:

Name
hilspy.open(url, codec="raw", *, clock=None, **codec_options) -> Channel open a channel (on a virtual clock with clock=)
Channel.send(message) / send(name, fields) encode and send
Channel.recv(timeout=None) -> Message | None next message (iterate the channel for all)
Channel.every(period, message_or_name, fields=None, *, duration=None) -> Periodic cyclic send from a Rust thread; update(), stop(), period, sent_count
Channel.close() also via with / async with
await Channel.asend(...), await Channel.arecv(timeout=None), async for msg in channel asyncio
hilspy.merge(*channels) (channel, message) from several buses as they arrive (for / async for)
hilspy.Message(name=None, fields=None, *, data=None, id=None, extended=None, fd=False) .name, .fields, msg["field"], .data, .id, .timestamp
hilspy.Clock(start=0.0) simulated time: advance(dt), advance_to(t), now, next_due
hilspy.register_transport(scheme, factory) / register_codec(name, factory) plugins
hilspy.Error bad URL/codec/option, encode/decode failure, closed channel

AUTOSAR E2E (alive counter + CRC)

The dbc codec protects chosen messages with an AUTOSAR E2E profile (autosar-e2e: profiles 4, 5, 6, 7, 8, 11, 22). Sending fills the counter (counting per message) and CRC; receiving checks them and adds an e2e field (Ok, CrcError, Repeated, WrongSequence, ...):

e2e = {
    "BrakeCmd": {"profile": 11, "data_id": 0x123, "signals": ["Crc", "Counter", "DataIdNibble"]},
    "SafetyStatus": {"profile": 5, "data_id": 0x4242, "offset": 0, "signals": ["Crc", "Counter"]},
}
with hilspy.open("can://can0", "dbc:chassis.dbc", e2e=e2e) as ecu:
    brake = ecu.every(0.01, "BrakeCmd", {"Pressure": 12.5, "Request": 1})  # counter + CRC per cycle
    for msg in ecu:
        if "e2e" in msg and msg["e2e"] != "Ok":
            print("E2E", msg.name, msg["e2e"])

signals names the signals the protection writes, so they may be left out even with strict. Offsets are AUTOSAR's (bits); profile 11 takes mode (nibble/both), crc_offset, counter_offset, nibble_offset; 22 data_id_list (16 values); all take max_delta_counter.

Synchronous simulation (virtual clock)

Devices that do I/O on a fixed period (cyclic CAN frames, a GNSS receiver at 10 Hz, a LiDAR streaming packets through each revolution) can run on simulated time instead of the wall clock. Open their channels with a shared hilspy.Clock; every send then happens inside clock.advance(dt), in time order, when the simulation steps past it:

clock = hilspy.Clock(start=carla_world_time)
ecu = hilspy.open("can://vcan0", "dbc:vehicle.dbc", clock=clock)
lidar = hilspy.open("udp://192.168.1.100:2368", "lidar:pandar_xt32", clock=clock)
speed = ecu.every(0.01, "VehicleSpeed", {"Speed": 0.0, "Gear": "D"})
scans = lidar.every(0.1, "scan", first_scan)

while running:
    world.tick()  # e.g. CARLA synchronous mode, 50 ms
    speed.update("VehicleSpeed", {"Speed": ego_speed(), "Gear": "D"})
    scans.update("scan", raycast_points())
    clock.advance(0.05)  # 5 speed frames, half a revolution of packets

On a clock:

  • every() sends at once, then each period as the clock passes it (no thread, nothing between steps).
  • A paced message's packets (a LiDAR revolution) go out as the clock reaches their times, so a 50 ms step sends the packets of 50 ms.
  • Messages are stamped with the clock's time. Codecs write that time (the SBF TOW, a LiDAR scan's timestamps), and received messages carry the time they arrived at.

asyncio and several buses at once

Every channel also has await ch.asend(...), await ch.arecv(timeout), async for msg in ch and async with. hilspy.merge(*channels) receives from many buses at once, yielding (channel, message) as messages arrive, with for or async for:

async with (
    hilspy.open("can://can0", "dbc:powertrain.dbc") as can0,
    hilspy.open("can://can1", "dbc:chassis.dbc") as can1,
    hilspy.open("tcp://192.168.3.1:28784", "sbf") as gnss,
    hilspy.merge(can0, can1, gnss) as buses,
):
    async for bus, msg in buses:
        if bus is gnss and msg.name == "PVTGeodetic":
            await can1.asend("GnssPosition", {"Lat": msg["latitude"], "Lon": msg["longitude"]})

Each bus is received on its own Rust thread, so awaiting never blocks the event loop and a quiet or slow bus does not delay the others. A channel has one consumer at a time: once received with arecv/async for/merge, its synchronous recv refuses.

A message with a name goes through the codec; a nameless one (Message(data=b"...", id=0x123)) is sent as is. Messages the codec does not know (a CAN id missing from the DBC) arrive nameless.

GNSS receiver simulation (SBF over TCP)

with hilspy.open("tcp://0.0.0.0:28784?listen", "sbf") as receiver:
    receiver.send(
        "PVTGeodetic", {"mode": 4, "latitude": lat_rad, "longitude": lon_rad, "height": 40.0}
    )
    receiver.send("AttEuler", {"heading": 90.0, "pitch": 0.0, "roll": 0.0})
    # tow/wnc default to the current GPS time

GNSS receivers (NMEA, UBX, RTCM) over serial

clock = hilspy.Clock(start=time.time())
gps = hilspy.open("serial:///dev/ttyUSB0?baud=115200", "nmea", talker="GN", clock=clock)
fix = gps.every(
    0.1, "GGA", {"latitude": lat, "longitude": lon, "fix_type": "Rtk", "fix_satellites": 14}
)

ublox = hilspy.open("serial:///dev/ttyACM0", "ubx")
for msg in ublox:  # interleaved NMEA is skipped
    if msg.name == "NAV-PVT":
        print(msg["lat"], msg["lon"], msg["fix_type"])

Decoded fields are the parsing crate's (names and units, e.g. NAV-PVT in degrees, metres and m/s); encoding takes the same fields, so a received sentence or packet can be sent on, edited or not. Times default to the message's time (the channel's clock). Repeated records (GSV satellites, MSM signal data) arrive as lists of dicts.

LiDAR (Hesai, Velodyne)

lidar:MODEL decodes a sensor's UDP data packets into point clouds, and encodes point clouds into the packets a sensor would send (to stand in for one in the loop). Sensors are profiles, not code:

Vendor Models (lidar:<name>; aliases in brackets)
Hesai pandar_40p, pandar_64, pandar_qt64, pandar_qt128, pandar_xt16, pandar_xt32, pandar_xt32m, pandar_128e3x (Pandar128), pandar_128e4x (OT128), pandar_at128
Velodyne vlp16 (Puck), vlp16_hires, vlp32c, vlp32mr, hdl32e, hdl64e, vls128 (Alpha Prime)

Names ignore case, - and _ (lidar:VLP-16, lidar:XT32).

# Driver side: one message per revolution.
with hilspy.open("udp://0.0.0.0:2368?listen", "lidar:pandar_xt32") as lidar:
    for scan in lidar:
        xyz = np.column_stack([np.frombuffer(scan[k], np.float32) for k in "xyz"])

# Sensor side: send one revolution of a simulated point cloud.
with hilspy.open("udp://192.168.1.100:2368", "lidar:vlp16") as sensor:
    sensor.send("scan", {"x": xs, "y": ys, "z": zs, "intensity": 100})

A scan has arrays (array.array, readable by numpy without copying) x, y, z (metres; x forward, y left, z up), intensity, channel, azimuth (degrees, clockwise from forward), distance, return and time (seconds after timestamp), plus timestamp, model, return_mode, rpm and packets. Sending takes x/y/z (each point goes to the channel of the nearest elevation) or distance/azimuth/channel, as lists, tuples, array.array or numpy arrays; a received scan can be sent back as is. Sending one revolution spreads its packets over the revolution's duration (100 ms at 600 rpm), as the sensor does, so a driver's socket buffer is not flooded; send returns when the last packet is out (pace=False sends them at once). Codec options: frame="packet" (a message per packet), phase (where scans start, degrees), rpm, return_mode and pace (encoding), profiles=FILE.

Profiles are protobuf messages (schema: lidar_profile.proto): packet layout, per-channel elevation / azimuth offset / firing time, return modes, timing and range. The defaults are written as text in lidar/profiles/ and committed compiled as lidar/profiles.binpb, which hilspy embeds. To describe another sensor, write a ProfileSet in text format, compile it with any protobuf tool (protoc --encode=hilspy.lidar.ProfileSet lidar_profile.proto < my_lidars.txtpb > my_lidars.binpb) and pass it with profiles="my_lidars.binpb"; it is searched before the defaults. Channel angles are nominal values from tier4/nebula's calibration files; per-unit calibration is not applied.

Plugins

A transport is any object with send(packet) and recv(timeout) (and optionally close()); a codec has encode(message) and decode(packet). Packets are nameless Messages.

class SerialTransport:
    def __init__(self, address: str, options: dict[str, str]):
        self.port = serial.Serial(address, int(options.get("baud", 115200)))

    def send(self, packet):
        self.port.write(packet.data)

    def recv(self, timeout):
        self.port.timeout = timeout
        data = self.port.read(self.port.in_waiting or 1)
        return hilspy.Message(data=data) if data else None


hilspy.register_transport("serial", SerialTransport)
gnss = hilspy.open("serial:///dev/ttyACM0?baud=921600", "sbf")

Rust crates register through hilspy_core::transport::register and hilspy_core::codec::register.

A virtual CAN interface for local testing:

sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set vcan0 mtu 72        # CAN FD
sudo ip link set up vcan0

Examples

uv run python examples/virtual_ecu.py                              # cyclic DBC message, in-process
uv run python examples/virtual_ecu.py --url can://vcan0
uv run python examples/gnss_simulator.py "tcp://127.0.0.1:28784?listen"
uv run python examples/gnss_simulator.py "file://sim.sbf?write" --seconds 5
uv run python examples/sbf_dump.py file://sim.sbf
uv run python examples/multi_bus.py                                # asyncio: CAN + GNSS at once

Development

uv sync                                 # build + dev dependencies (cantools for cross-checks)
cargo test -p hilspy-core               # Rust unit tests
uv run pytest                           # Python tests
HILSPY_TEST_VCAN=vcan0 uv run pytest tests/test_socketcan.py   # needs a vcan interface
uv run pre-commit run --all-files       # ruff, mypy, cargo fmt, clippy

Design notes: docs/design.md.

License

Apache-2.0

Metadata

Release files for hilspy 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hilspy 0.7.0
File Size Uploaded
hilspy-0.7.0.tar.gz 152.1 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for hilspy 0.7.0
File
hilspy-0.7.0-cp310-abi3-win_amd64.whl CPython 3.10 abi3 Windows x86-64 Details
hilspy-0.7.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.10 abi3 Linux glibc 2.17+ x86-64 Details
hilspy-0.7.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.10 abi3 Linux glibc 2.17+ ARM64 Details
hilspy-0.7.0-cp310-abi3-macosx_11_0_arm64.whl CPython 3.10 abi3 macOS 11.0+ ARM64 Details
hilspy-0.7.0-cp310-abi3-macosx_10_12_x86_64.whl CPython 3.10 abi3 macOS 10.12+ x86-64 Details

Total release size: 11.4 MB

Release files / hilspy-0.7.0.tar.gz

Download URL hilspy-0.7.0.tar.gz
Size 152.1 kB
Tags Source
SHA-256 checksum
How to use checksums
fbf6fa6cf27f49d17c14a65338587d920d958ebcf0e0a8981c4e33006e715437
BLAKE2b-256 checksum
How to use checksums
15036569eb9cc454f90f215f237f1f045f947bc9671338b987e82d74304ae00f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 15, 2026.

Transparency log

Release files / hilspy-0.7.0-cp310-abi3-win_amd64.whl

Download URL hilspy-0.7.0-cp310-abi3-win_amd64.whl
Size 2.3 MB
Tags CPython 3.10 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
368f5e11f752fae773590d59f84f31258ab750df9a0b1f5c744904ceed30e2aa
BLAKE2b-256 checksum
How to use checksums
1d36af9cc8b4eb4818492f72c8ef421a4396196955ace3b34117283f3e6072c7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 15, 2026.

Transparency log

Release files / hilspy-0.7.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL hilspy-0.7.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 2.3 MB
Tags CPython 3.10 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
27921a25ed9428d478d625dd285b85a9846e46eca3e79843ff42fe92e13506a0
BLAKE2b-256 checksum
How to use checksums
ebf578d2eb03f38360fea2a3343ed1489b76bb0adf3a763e7c7066d55541cd24
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 15, 2026.

Transparency log

Release files / hilspy-0.7.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL hilspy-0.7.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 2.2 MB
Tags CPython 3.10 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
5e3d73cf5428ab96bd09553101db9f803e091c45986859f6c9513c51aa8f3e34
BLAKE2b-256 checksum
How to use checksums
947f9e9be6fd76c928f2bf5fe89608182f48499fb1f8e87fff1e4ae9b67c7c6d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 15, 2026.

Transparency log

Release files / hilspy-0.7.0-cp310-abi3-macosx_11_0_arm64.whl

Download URL hilspy-0.7.0-cp310-abi3-macosx_11_0_arm64.whl
Size 2.1 MB
Tags CPython 3.10 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
8929d2ff1aff357d71686e1e3793854d96cf5ce6bf5e3c95cb38319fe8b0da4f
BLAKE2b-256 checksum
How to use checksums
e8b587f5da97b142f6f6bf511b7943071113e6316dd08a761c15d312f43b7d2a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 15, 2026.

Transparency log

Release files / hilspy-0.7.0-cp310-abi3-macosx_10_12_x86_64.whl

Download URL hilspy-0.7.0-cp310-abi3-macosx_10_12_x86_64.whl
Size 2.3 MB
Tags CPython 3.10 abi3 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
28945208ef26c3e68a6c331c3363407a592a42ed58653a4f16e60139aab7e361
BLAKE2b-256 checksum
How to use checksums
9aa07a5dc5fac56acb901c5abfa643b9324ccf1a7df833496f6061e18c549751
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 15, 2026.

Transparency log

Release history Release notifications | RSS feed

0.12.0

6 release files

0.11.0

6 release files

0.10.0

6 release files

0.9.0

6 release files

0.8.0

6 release files

This release

0.7.0 This release

6 release files

0.6.0

6 release files

0.5.0

6 release files

0.4.1

6 release files

0.4.0

6 release files

0.3.0

6 release files

0.2.0

6 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page