Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7361ddf
Updated launch file and sensor schema.
mgiardino-be Feb 25, 2026
dcfba97
Added param and schema file for FT120.
mgiardino-be Feb 25, 2026
a2f1e69
Added the sensor in common headers.
mgiardino-be Feb 25, 2026
5f33957
Added HesaiSolidStateCalibration class to read and write the calibrat…
mgiardino-be Feb 25, 2026
94b643d
Added definitions for header, unit and data block.
mgiardino-be Feb 25, 2026
d7d4d26
Added structures to manage sensor configuration and status. Renamed s…
mgiardino-be Feb 25, 2026
ee41fc2
Added the class that computes the lookup tables for azimuth and eleva…
mgiardino-be Feb 25, 2026
72e5b07
Integratedthe new angle corrector into HesaiSensor.
mgiardino-be Feb 25, 2026
57afc90
Added the decoder class for solid state sensor. Some notes:
mgiardino-be Feb 25, 2026
30a841c
Include the solid state calibration in the ROS wrapper.
mgiardino-be Feb 25, 2026
94f3387
ci(pre-commit): autofix
pre-commit-ci[bot] Feb 26, 2026
585e85c
Add includes as requested by cpplint.
mgiardino-be Mar 4, 2026
979f640
ci(pre-commit): autofix
pre-commit-ci[bot] Mar 4, 2026
1656be2
Added safe read and size checking when reading sensor calibration file.
mgiardino-be Mar 16, 2026
7d6338b
Renamed return_num in packet header structure to first_block_return.
mgiardino-be Mar 16, 2026
ffd103d
Changed AES signature type.
mgiardino-be Mar 16, 2026
0c9b5d9
Renamed variables to match coding style and improve
mgiardino-be Mar 16, 2026
b942dc9
ci(pre-commit): autofix
pre-commit-ci[bot] Mar 16, 2026
49c8f67
Bugfix: write points into output_frame_ when the first packet of a ne…
mgiardino-be Mar 16, 2026
77a6e03
ci(pre-commit): autofix
pre-commit-ci[bot] Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/nebula/launch/nebula_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"PandarXT16",
"PandarXT32",
"PandarXT32M",
"PandarFT120",
]
SENSOR_MODELS_ROBOSENSE = ["Bpearl", "Helios"]
SENSOR_MODELS_CONTINENTAL = ["ARS548", "SRR520"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ enum class SensorModel : uint8_t {
HESAI_PANDARAT128,
HESAI_PANDAR128_E3X,
HESAI_PANDAR128_E4X,
HESAI_PANDARFT120,
VELODYNE_VLS128,
VELODYNE_HDL64,
VELODYNE_VLP32,
Expand Down Expand Up @@ -257,6 +258,9 @@ inline std::ostream & operator<<(std::ostream & os, nebula::drivers::SensorModel
case SensorModel::HESAI_PANDAR128_E4X:
os << "Pandar128_E4X_OT";
break;
case SensorModel::HESAI_PANDARFT120:
os << "PandarFT120";
break;
case SensorModel::VELODYNE_VLS128:
os << "VLS128";
break;
Expand Down Expand Up @@ -409,6 +413,7 @@ inline SensorModel sensor_model_from_string(const std::string & sensor_model)
if (sensor_model == "PandarQT64") return SensorModel::HESAI_PANDARQT64;
if (sensor_model == "PandarQT128") return SensorModel::HESAI_PANDARQT128;
if (sensor_model == "Pandar128E4X") return SensorModel::HESAI_PANDAR128_E4X;
if (sensor_model == "PandarFT120") return SensorModel::HESAI_PANDARFT120;
// Velodyne
if (sensor_model == "VLS128") return SensorModel::VELODYNE_VLS128;
if (sensor_model == "HDL64") return SensorModel::VELODYNE_HDL64;
Expand Down Expand Up @@ -451,6 +456,8 @@ inline std::string sensor_model_to_string(const SensorModel & sensor_model)
return "PandarQT128";
case SensorModel::HESAI_PANDAR128_E4X:
return "Pandar128E4X";
case SensorModel::HESAI_PANDARFT120:
return "PandarFT120";
// Velodyne
case SensorModel::VELODYNE_VLS128:
return "VLS128";
Expand Down
40 changes: 40 additions & 0 deletions src/nebula_hesai/nebula_hesai/config/PandarFT120.param.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**:
ros__parameters:
host_ip: 192.168.1.10
sensor_ip: 192.168.1.201
multicast_ip: ""
data_port: 2368
gnss_port: 10110
udp_socket_receive_buffer_size_bytes: 5400000
packet_mtu_size: 1500
launch_hw: true
setup_sensor: true
udp_only: false
frame_id: hesai
diag_span: 1000
min_range: 0.3
max_range: 22.0
cloud_min_angle: 40
cloud_max_angle: 139
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(unconfirmed) Given angluar azimuth resolution of 0.625deg, rounding down here could cause the last column to be filtered out.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Nebula would have to be changed to allow centi- or milli-degree settings here to fix this)

Copy link
Author

@mgiardino-be mgiardino-be Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should think how to verify the issue.
Relevant part is at the end of hesai_decoder.hpp.

  • we received a packet for which passed_timestamp_reset_angle()==true (a new scan)
  • we should always assign new_scan_timestamp_ns to output_frame_, as we are processing a new scan but the buffers are not swapped yet;

For this reason, we should always impose that cut_angle != cloud_max_angle: due to inner working of the sensor, it made no sense to have a cut_angle which is not associated to the sensor minimum (= transmit old decode frame when we start processing a new one) or maximum column (we have completed one frame, send it). If cut_angle != cloud_max_angle, the timestamp is going to be assigned to output_frame_ and the procedure should be correct.

sync_angle: 40 # 40-139
cut_angle: 139.0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(unconfirmed) might cause a column of points from an old scan to turn up in the new scan.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is linked to above comment.
Worse, there is a bug in the code (at line 575 in hesai_decoder.hpp) so that the first column of a new scan is written into the frame for the old one: I'll apply a bugfix.

sensor_model: PandarFT120
calibration_file: $(find-pkg-share nebula_hesai_decoders)/calibration/$(var sensor_model).dat
calibration_download_enabled: true
rotation_speed: 600
return_mode: Strongest
ptp_profile: automotive
ptp_domain: 0
ptp_transport_type: L2
ptp_switch_type: TSN
ptp_lock_threshold: 1 # 1-100
retry_hw: true
dual_return_distance_threshold: 0.1
diagnostics:
pointcloud_publish_rate:
frequency_ok:
min_hz: 9.5
max_hz: 10.5
frequency_warn:
min_hz: 9.0
max_hz: 11.0
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<choice value="PandarXT16" />
<choice value="PandarXT32" />
<choice value="PandarXT32M" />
<choice value="PandarFT120" />
</arg>
<arg name="launch_hw" default="true" description="Whether to connect to a real sensor (true) or to accept packet messages (false).">
<choice value="true" />
Expand Down
157 changes: 157 additions & 0 deletions src/nebula_hesai/nebula_hesai/schema/PandarFT120.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LiDAR Hesai FT120 parameters.",
"type": "object",
"definitions": {
"PandarFT120": {
"type": "object",
"properties": {
"host_ip": {
"$ref": "sub/communication.json#/definitions/host_ip"
},
"sensor_ip": {
"$ref": "sub/communication.json#/definitions/sensor_ip"
},
"multicast_ip": {
"$ref": "sub/lidar_hesai.json#/definitions/multicast_ip"
},
"data_port": {
"$ref": "sub/communication.json#/definitions/data_port"
},
"udp_socket_receive_buffer_size_bytes": {
"$ref": "sub/lidar_hesai.json#/definitions/udp_socket_receive_buffer_size_bytes"
},
"packet_mtu_size": {
"$ref": "sub/communication.json#/definitions/packet_mtu_size"
},
"launch_hw": {
"$ref": "sub/hardware.json#/definitions/launch_hw"
},
"setup_sensor": {
"$ref": "sub/hardware.json#/definitions/setup_sensor"
},
"udp_only": {
"$ref": "sub/hardware.json#/definitions/udp_only"
},
"frame_id": {
"$ref": "sub/topic.json#/definitions/frame_id"
},
"diag_span": {
"$ref": "sub/topic.json#/definitions/diag_span"
},
"sync_angle": {
"$ref": "sub/lidar_hesai.json#/definitions/sync_angle",
"minimum": 40,
"maximum": 139,
"default": 40
},
"cut_angle": {
"$ref": "sub/lidar_hesai.json#/definitions/cut_angle",
"minimum": 40.0,
"maximum": 139.0,
"default": 40.0
},
"sensor_model": {
"$ref": "sub/lidar_hesai.json#/definitions/sensor_model",
"enum": [
"PandarFT120"
]
},
"calibration_file": {
"$ref": "sub/lidar_hesai.json#/definitions/calibration_file"
},
"return_mode": {
"$ref": "sub/misc.json#/definitions/return_mode",
"enum": [
"Strongest",
"First",
"FirstStrongest"
]
},
"ptp_profile": {
"$ref": "sub/lidar_hesai.json#/definitions/ptp_profile",
"default": "automotive"
},
"ptp_domain": {
"$ref": "sub/lidar_hesai.json#/definitions/ptp_domain"
},
"ptp_transport_type": {
"$ref": "sub/lidar_hesai.json#/definitions/ptp_transport_type",
"default": "L2"
},
"ptp_switch_type": {
"$ref": "sub/lidar_hesai.json#/definitions/ptp_switch_type"
},
"ptp_lock_threshold": {
"$ref": "sub/lidar_hesai.json#/definitions/ptp_lock_threshold"
},
"retry_hw": {
"$ref": "sub/hardware.json#/definitions/retry_hw"
},
"dual_return_distance_threshold": {
"$ref": "sub/misc.json#/definitions/dual_return_distance_threshold"
},
"point_filters": {
"$ref": "sub/misc.json#/definitions/point_filters"
},
"diagnostics": {
"$ref": "sub/lidar_hesai.json#/definitions/diagnostics",
"required": [
"pointcloud_publish_rate",
"packet_loss"
]
},
"sync_diagnostics": {
"$ref": "sub/misc.json#/definitions/sync_diagnostics"
}
},
"required": [
"host_ip",
"sensor_ip",
"multicast_ip",
"data_port",
"udp_socket_receive_buffer_size_bytes",
"packet_mtu_size",
"launch_hw",
"setup_sensor",
"udp_only",
"frame_id",
"diag_span",
"cloud_min_angle",
"cloud_max_angle",
"sync_angle",
"cut_angle",
"sensor_model",
"calibration_file",
"return_mode",
"ptp_profile",
"ptp_domain",
"ptp_transport_type",
"ptp_switch_type",
"ptp_lock_threshold",
"retry_hw",
"dual_return_distance_threshold",
"diagnostics"
],
"additionalProperties": false
}
},
"properties": {
"/**": {
"type": "object",
"properties": {
"ros__parameters": {
"$ref": "#/definitions/PandarFT120"
},
"additionalProperties": false
},
"required": [
"ros__parameters"
]
}
},
"required": [
"/**"
],
"additionalProperties": false
}
3 changes: 2 additions & 1 deletion src/nebula_hesai/nebula_hesai/schema/lidar_hesai.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"PandarQT64",
"PandarQT128",
"Pandar128E4X",
"PandarAT128"
"PandarAT128",
"PandarFT120"
]
},
"calibration_file": {
Expand Down
5 changes: 4 additions & 1 deletion src/nebula_hesai/nebula_hesai/src/hesai_ros_wrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ HesaiRosWrapper::HesaiRosWrapper(const rclcpp::NodeOptions & options)

bool lidar_range_supported =
sensor_cfg_ptr_->sensor_model != drivers::SensorModel::HESAI_PANDARAT128 &&
sensor_cfg_ptr_->sensor_model != drivers::SensorModel::HESAI_PANDAR64;
sensor_cfg_ptr_->sensor_model != drivers::SensorModel::HESAI_PANDAR64 &&
sensor_cfg_ptr_->sensor_model != drivers::SensorModel::HESAI_PANDARFT120;

if (hw_interface_wrapper_ && !use_udp_only && lidar_range_supported) {
auto status =
Expand Down Expand Up @@ -633,6 +634,8 @@ HesaiRosWrapper::get_calibration_result_t HesaiRosWrapper::get_calibration_data(

if (sensor_cfg_ptr_->sensor_model == drivers::SensorModel::HESAI_PANDARAT128) {
calib = std::make_shared<drivers::HesaiCorrection>();
} else if (sensor_cfg_ptr_->sensor_model == drivers::SensorModel::HESAI_PANDARFT120) {
calib = std::make_shared<drivers::HesaiSolidStateCalibration>();
} else {
calib = std::make_shared<drivers::HesaiCalibrationConfiguration>();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,86 @@ struct HesaiCorrection : public HesaiCalibrationConfigurationBase
}
};

/// @brief struct for Hesai correction configuration for solid state sensors (for FT120)
struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase
{
public:
std::vector<int32_t> azimuth_adjust;
std::vector<int32_t> elevation_adjust;

uint32_t col_count;
uint32_t row_count;
uint32_t resolution;

nebula::Status load_from_bytes(const std::vector<uint8_t> & buf) override
{
// get the matrix info from buffer
col_count = buf.at(6);
row_count = buf.at(7);
resolution = buf.at(8);

const auto count{col_count * row_count};
const auto count_bytes{4 * count};

// size check for the upcoming memcpy operations (0-8 + 2 arrays)
if (buf.size() < (9 + 2 * count_bytes)) {
return Status::INVALID_CALIBRATION_FILE;
}

auto ref = &(buf[9]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perform size checking here (are there really count_bytes after the given offset?)


azimuth_adjust.resize(count);
std::memcpy(azimuth_adjust.data(), ref, count_bytes);

elevation_adjust.resize(count);
std::memcpy(elevation_adjust.data(), ref + count_bytes, count_bytes);

return Status::OK;
}

inline nebula::Status load_from_file(const std::string & calibration_file) override
{
std::ifstream stream(calibration_file, std::ios::in | std::ios::binary);
std::vector<uint8_t> contents(
(std::istreambuf_iterator<char>(stream)), std::istreambuf_iterator<char>());

load_from_bytes(contents);

return Status::OK;
}

// from HesaiCorrection
nebula::Status save_to_file_from_bytes(
const std::string & calibration_file, const std::vector<uint8_t> & buf) override
{
std::ofstream ofs(calibration_file, std::ios::trunc | std::ios::binary);
if (!ofs) {
std::cerr << "Could not create file: " << calibration_file << "\n";
return Status::CANNOT_SAVE_FILE;
}
bool sop_received = false;
for (const auto & byte : buf) {
if (!sop_received) {
if (byte == 0xEE) {
sop_received = true;
}
}
if (sop_received) {
ofs << byte;
}
}
ofs.close();
if (sop_received) return Status::OK;
return Status::INVALID_CALIBRATION_FILE;
}

[[nodiscard]] std::tuple<float, float> get_fov_padding() const override
{
// For FT120 should be enough
return {-0.1, 0.1};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(note for self) check

}
};

/*
<option value="0">Last Return</option>
<option value="1">Strongest Return</option>
Expand Down Expand Up @@ -623,6 +703,7 @@ inline ReturnMode return_mode_from_string_hesai(
case SensorModel::HESAI_PANDAR128_E3X:
case SensorModel::HESAI_PANDAR128_E4X:
case SensorModel::HESAI_PANDARQT128:
case SensorModel::HESAI_PANDARFT120:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to https://www.hesaitech.com/wp-content/uploads/2025/05/FT120_User_Manual_F01-en-250510.pdf, FT120 only supports Strongest, First and FirstStrongest modes. Please adjust accordingly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the sensor to this case block because it already process the correct strings for the return modes. Do you prefer to have a dedicated case block?

if (return_mode == "Last") return ReturnMode::LAST;
if (return_mode == "Strongest") return ReturnMode::STRONGEST;
if (return_mode == "Dual" || return_mode == "LastStrongest")
Expand Down Expand Up @@ -665,6 +746,7 @@ inline ReturnMode return_mode_from_int_hesai(
case SensorModel::HESAI_PANDAR128_E3X:
case SensorModel::HESAI_PANDAR128_E4X:
case SensorModel::HESAI_PANDARQT128:
case SensorModel::HESAI_PANDARFT120:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

if (return_mode == 0) return ReturnMode::LAST;
if (return_mode == 1) return ReturnMode::STRONGEST;
if (return_mode == 2) return ReturnMode::DUAL_LAST_STRONGEST;
Expand Down Expand Up @@ -705,6 +787,7 @@ inline int int_from_return_mode_hesai(
case SensorModel::HESAI_PANDAR128_E3X:
case SensorModel::HESAI_PANDAR128_E4X:
case SensorModel::HESAI_PANDARQT128:
case SensorModel::HESAI_PANDARFT120:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

if (return_mode == ReturnMode::LAST) return 0;
if (return_mode == ReturnMode::STRONGEST) return 1;
if (return_mode == ReturnMode::DUAL || return_mode == ReturnMode::DUAL_LAST_STRONGEST)
Expand Down
Binary file not shown.
Loading
Loading