Vehicle — AIR Twizy¶
ROS2-based vehicle stack for the AIR-UFG team's StreetDrone Twizy. This workspace covers both simulation (Gazebo) and real-world operation via CAN bus.
Source: AIR-UFG/air_twizy_simulation (included as a submodule at workspace/twizy).
Workspace Structure¶
workspace/twizy/
├── docker/
│ ├── docker-compose.yml # Standalone vehicle compose
│ └── Dockerfile
├── ros_packages/
│ ├── vehicle_interface_packages/
│ │ ├── ros2_socketcan/ # CAN bus ROS2 interface
│ │ └── SD-VehicleInterface/ # StreetDrone XCU integration
│ └── vehicle_simulation_packages/
│ ├── air_description/ # URDF/mesh descriptions
│ ├── air_sim/ # Gazebo world and plugins
│ └── vehicle_control_plugin/
└── utils/
├── run.sh # Container launcher with env flags
├── build_docker.sh
├── bash_container.sh # Shell into running container
└── record_bag.sh
Quick Start¶
Simulation (Gazebo)¶
Once Gazebo opens, press Play. Then in another terminal:
docker compose exec carro bash
# Keyboard control
ros2 run vehicle_control sd_teleop_keyboard_control.py
Controls:
| Key | Action |
|---|---|
| W | Increase velocity |
| S | Decrease velocity |
| A | Turn left |
| D | Turn right |
| X | Stop |
Real vehicle¶
# Bring up CAN interface on host
sudo ip link set can0 type can bitrate 500000
sudo ip link set can0 up
# Start container with CAN and vehicle interface enabled
TWIZY_INTERFACE=true TWIZY_CAN_PORT=can0 docker compose up -d carro
Environment Variables¶
| Variable | Description | Default |
|---|---|---|
TWIZY_GPU |
Enable GPU for point cloud processing | false |
TWIZY_LIDAR |
Launch LiDAR integration | false |
TWIZY_INTERFACE |
Launch vehicle interface (CAN) | true |
TWIZY_CAN_PORT |
Host CAN interface name | can0 |
TWIZY_GPU / NVIDIA_RUNTIME |
NVIDIA runtime for GPU containers | runc |
Recording¶
# Inside the container
# Record specific topics
./utils/record_bag.sh my_run specific /velodyne_points /camera/image_raw
# Record all topics
./utils/record_bag.sh my_run all
Bags are stored in workspace/twizy/shared_folder/ (volume-mounted from host).
Vehicle Interface Packages¶
The vehicle_interface_packages workspace provides ROS2-based communication with the StreetDrone Twizy hardware via CAN bus.
ros2_socketcan¶
Handles CAN bus communication in ROS2 using the SocketCAN protocol.
- Implements ROS2 nodes for CAN bus interfacing
- Publishes/subscribes
can_msgs/Framemessages - Supports standard SocketCAN kernel driver (
can0,vcan0, etc.)
SD-VehicleInterface¶
Integrates the StreetDrone Xenos Control Unit (XCU) with the ROS2 navigation stack.
- Translates
ackermann_msgs/AckermannDriveStampedcommands to CAN frames - Publishes vehicle speed, steering angle, and status
Subscribed topics:
| Topic | Type | Description |
|-------|------|-------------|
| /sd_control | ackermann_msgs/AckermannDriveStamped | Drive commands |
Published topics:
| Topic | Type | Description |
|-------|------|-------------|
| /sd_status | custom | Vehicle state (speed, steering, mode) |
CAN Bus Setup¶
# Standard CAN (500 kbps — StreetDrone default)
sudo ip link set can0 type can bitrate 500000
sudo ip link set can0 up
# Verify
ip link show can0
candump can0 # should show CAN frames from the XCU
Vehicle Operation — Real Example¶
1 — SSH into the vehicle¶
twizy resolves to 100.122.121.134 via NetBird VPN. Both machines must have NetBird connected before this works (netbird status on each).
First connection
On the first SSH from a new machine, you will see a key fingerprint prompt:
The authenticity of host 'twizy (100.122.121.134)' can't be established.
ED25519 key fingerprint is SHA256:6KA7LU04JZoQ+1IiHqn3uDhkX+sSFLTbj2AMHe+xGgY.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
yes (not just y — SSH rejects the abbreviated answer). The key is then saved permanently.
2 — Navigate to the repository¶
The repository lives at ~/tmp_mota/air_twizy_simulation/ on the vehicle. There are similar directories for other team members (tmp_simoes/, tmp_victor/) — use tmp_mota/.
The repo structure at this path:
air_twizy_simulation/
├── docker/
│ └── docker-compose.yml
├── ros_packages/
├── shared_folder/
└── utils/
├── run.sh ← use this to start/restart containers
├── bash_container.sh ← use this to enter the container
├── record_bag.sh
├── direct_control/
│ └── direct_teleop.py
└── ...
3 — Start the containers¶
Expected output:
Running without NVIDIA GPU support.
[+] Running 2/2
✔ Container fastdds_server Started
✔ Container air_car_container Started
This starts two containers:
- air_car_container — main vehicle container (ROS2 + SD-VehicleInterface)
- fastdds_server — FastDDS Discovery Server on port 11811
Do NOT use docker compose up directly
Running docker compose up from the repo root will fail with no configuration file provided: not found. The compose file is at docker/docker-compose.yml and requires environment variables that only run.sh sets correctly. Always use ./utils/run.sh.
Run from the repo root, not from utils/
run.sh references docker/docker-compose.yml relative to its working directory. If you are inside utils/ when you run it, the path resolution fails. Always cd ~/tmp_mota/air_twizy_simulation first.
Non-blocking warnings from run.sh
These appear every time and can be ignored:
4 — Enter the container¶
This opens a bash shell inside air_car_container as root.
Source ROS2 before running any ros2 command
The container's interactive bash shell does not automatically source ROS2. You must run this every time you open a new shell inside the container:
Without it,ros2 commands will not be found and Python scripts that import rclpy will fail silently or with import errors.
The workspace packages (sd_vehicle_interface, ros2_socketcan, etc.) are already sourced via the container entrypoint — only the base ROS2 install needs this manual source.
5 — Launch the vehicle interface¶
ros2 launch sd_vehicle_interface sd_vehicle_interface.launch.xml \
sd_vehicle:=twizy \
sd_gps_imu:=peak \
sd_speed_source:=vehicle_can_speed
This starts three ROS2 nodes:
- sd_vehicle_interface_node — translates ROS2 commands to CAN frames
- socket_can_receiver_node_exe — reads CAN frames from the bus
- socket_can_sender_node_exe — writes CAN frames to the bus
The launch is working correctly when you see:
[socket_can_receiver_node_exe-2] interface: can2
[socket_can_receiver_node_exe-2] applied filters: 0:0
The line applied filters: 0:0 confirms the CAN socket is open and the XCU is sending frames. If this line does not appear within ~2 seconds of startup, the wrong CAN port is selected.
CAN interface — which port to use¶
The StreetDrone XCU is physically connected to can2 on this vehicle. The default CAN_PORT=can2 inside the container is already correct.
| Interface | What happens | Meaning |
|---|---|---|
can2 |
applied filters: 0:0 — no errors |
Correct — XCU is on this bus |
can0 |
applied filters: 0:0 — but no XCU data flows |
Interface is UP but XCU not connected here |
can1 |
applied filters: 0:0 then immediately: Error sending: No buffer space available + Error receiving: CAN Receive Timeout |
Bus is UP but no node responds — buffer fills immediately |
can0 and can1 do not cause an immediate crash
Both interfaces start without error and show applied filters: 0:0. The problem only becomes apparent when you try to send a command — with can1 you see errors immediately; with can0 the interface appears healthy but the XCU never receives or sends data. Always verify applied filters: 0:0 appeared and that no WARN lines follow.
If for any reason CAN_PORT was changed, reset it:
export CAN_PORT=can2
ros2 launch sd_vehicle_interface sd_vehicle_interface.launch.xml \
sd_vehicle:=twizy \
sd_gps_imu:=peak \
sd_speed_source:=vehicle_can_speed
Environment variables inside the container¶
Run env inside the container to verify the full environment. The values confirmed in production:
ROS_DOMAIN_ID=0
ROS_SUPER_CLIENT=true
ROS_DISCOVERY_SERVER=twizy:11811
RMW_IMPLEMENTATION=rmw_fastrtps_cpp
ROS_LOCALHOST_ONLY=0
CAN_PORT=can2
INTERFACE=true
LIDAR=false
GPU=false
6 — Keyboard teleoperation (alternative to Xbox controller)¶
Open a second terminal on the vehicle (new SSH session or tmux pane) and enter the container again:
Then inside the container:
Expected output:
Reading from the keyboard and Publishing to TwistStamped!
Uses "w, a, s, d, x" keys
---------------------------
Move forward: 'w'
Move backward: 's'
Turn left: 'a'
Turn right: 'd'
Stop: 'x'
CTRL-C to quit
Torque Setpoint: 0.0, Steering Value: 0.0
The script publishes sd_msgs/DirectControl to /direct_control_cmd in real time.
Steering behavior:
- Each keypress increments or decrements the steering by 5 units
- The value accumulates while the key is held: one press of a → -5, two presses → -10, etc.
- Maximum range: -55 to +55 (the script clamps at this limit)
- Releasing the key does not center the steering — you must press the opposite direction or x
Example observed values during a test session:
Steering Value: -5.0 ← one 'd' press
Steering Value: -5.0
Steering Value: 5.0 ← switched to 'a'
Steering Value: 10.0
Steering Value: -55.0 ← held 'a' repeatedly until clamp
Steering Value: 0.0 ← returned to center
This terminal must be separate from the vehicle interface terminal
direct_teleop.py only publishes commands. The sd_vehicle_interface node (step 5) must be running in the other terminal to actually send those commands over CAN to the XCU.
7 — Stopping the containers¶
Do NOT use docker compose down — it requires the same environment variables that run.sh sets, and will fail with variable-not-set warnings if called from a plain shell:
WARN: The "CAN_PORT" variable is not set. Defaulting to a blank string.
invalid spec: ::rw: empty section between colons
Use docker stop by container name instead:
Verify they are stopped:
To restart: run ./utils/run.sh again from the repo root — it stops any existing containers and recreates them.