From 7361ddfd1adedc205e58ddb5105ac96c8fd5f156 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 11:58:23 +0100 Subject: [PATCH 01/20] Updated launch file and sensor schema. --- src/nebula/launch/nebula_launch.py | 1 + src/nebula_hesai/nebula_hesai/launch/hesai_launch_all_hw.xml | 1 + src/nebula_hesai/nebula_hesai/schema/lidar_hesai.json | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/nebula/launch/nebula_launch.py b/src/nebula/launch/nebula_launch.py index 72a4c6f82..8f88621cc 100644 --- a/src/nebula/launch/nebula_launch.py +++ b/src/nebula/launch/nebula_launch.py @@ -33,6 +33,7 @@ "PandarXT16", "PandarXT32", "PandarXT32M", + "PandarFT120", ] SENSOR_MODELS_ROBOSENSE = ["Bpearl", "Helios"] SENSOR_MODELS_CONTINENTAL = ["ARS548", "SRR520"] diff --git a/src/nebula_hesai/nebula_hesai/launch/hesai_launch_all_hw.xml b/src/nebula_hesai/nebula_hesai/launch/hesai_launch_all_hw.xml index b0bca0a97..11ede424c 100644 --- a/src/nebula_hesai/nebula_hesai/launch/hesai_launch_all_hw.xml +++ b/src/nebula_hesai/nebula_hesai/launch/hesai_launch_all_hw.xml @@ -10,6 +10,7 @@ + diff --git a/src/nebula_hesai/nebula_hesai/schema/lidar_hesai.json b/src/nebula_hesai/nebula_hesai/schema/lidar_hesai.json index b020f4a64..3735597c1 100644 --- a/src/nebula_hesai/nebula_hesai/schema/lidar_hesai.json +++ b/src/nebula_hesai/nebula_hesai/schema/lidar_hesai.json @@ -14,7 +14,8 @@ "PandarQT64", "PandarQT128", "Pandar128E4X", - "PandarAT128" + "PandarAT128", + "PandarFT120" ] }, "calibration_file": { From dcfba97a95d4751c945bf3be208132065f9d4e49 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 11:59:25 +0100 Subject: [PATCH 02/20] Added param and schema file for FT120. --- .../config/PandarFT120.param.yaml | 40 +++++ .../schema/PandarFT120.schema.json | 157 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/nebula_hesai/nebula_hesai/config/PandarFT120.param.yaml create mode 100644 src/nebula_hesai/nebula_hesai/schema/PandarFT120.schema.json diff --git a/src/nebula_hesai/nebula_hesai/config/PandarFT120.param.yaml b/src/nebula_hesai/nebula_hesai/config/PandarFT120.param.yaml new file mode 100644 index 000000000..657b1838b --- /dev/null +++ b/src/nebula_hesai/nebula_hesai/config/PandarFT120.param.yaml @@ -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 + sync_angle: 40 # 40-139 + cut_angle: 139.0 + 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 diff --git a/src/nebula_hesai/nebula_hesai/schema/PandarFT120.schema.json b/src/nebula_hesai/nebula_hesai/schema/PandarFT120.schema.json new file mode 100644 index 000000000..8e93805db --- /dev/null +++ b/src/nebula_hesai/nebula_hesai/schema/PandarFT120.schema.json @@ -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 +} From a2f1e69298cd280d09855a49544a3a582daf7c22 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 12:08:29 +0100 Subject: [PATCH 03/20] Added the sensor in common headers. --- .../include/nebula_core_common/nebula_common.hpp | 7 +++++++ .../include/nebula_hesai_common/hesai_common.hpp | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/nebula_core/nebula_core_common/include/nebula_core_common/nebula_common.hpp b/src/nebula_core/nebula_core_common/include/nebula_core_common/nebula_common.hpp index 0bcfdd490..5b918d6ad 100644 --- a/src/nebula_core/nebula_core_common/include/nebula_core_common/nebula_common.hpp +++ b/src/nebula_core/nebula_core_common/include/nebula_core_common/nebula_common.hpp @@ -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, @@ -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; @@ -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; @@ -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"; diff --git a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp index 8945572a2..22bb1870c 100644 --- a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp +++ b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp @@ -623,6 +623,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: if (return_mode == "Last") return ReturnMode::LAST; if (return_mode == "Strongest") return ReturnMode::STRONGEST; if (return_mode == "Dual" || return_mode == "LastStrongest") @@ -665,6 +666,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: if (return_mode == 0) return ReturnMode::LAST; if (return_mode == 1) return ReturnMode::STRONGEST; if (return_mode == 2) return ReturnMode::DUAL_LAST_STRONGEST; @@ -705,6 +707,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: 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) From 5f33957ebc54a61c7554fc7ba7166f00e1e879d2 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 12:12:34 +0100 Subject: [PATCH 04/20] Added HesaiSolidStateCalibration class to read and write the calibration binary file. Added calibration file from a sensor. --- .../nebula_hesai_common/hesai_common.hpp | 77 +++++++++++++++++- .../calibration/PandarFT120.dat | Bin 0 -> 153641 bytes 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/nebula_hesai/nebula_hesai_decoders/calibration/PandarFT120.dat diff --git a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp index 22bb1870c..70d4d130f 100644 --- a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp +++ b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp @@ -591,6 +591,81 @@ struct HesaiCorrection : public HesaiCalibrationConfigurationBase } }; +/// @brief struct for Hesai correction configuration for solid state sensors (for FT120) +struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase +{ + public: + std::vector azimuth_adjust; + std::vector elevation_adjust; + + uint32_t col_count; + uint32_t row_count; + uint32_t resolution; + + nebula::Status load_from_bytes(const std::vector & buf) override + { + // get the matrix info from buffer + auto raw_ptr = buf.data(); + col_count = raw_ptr[6]; + row_count = raw_ptr[7]; + resolution = raw_ptr[8]; + + const auto count{col_count*row_count}; + const auto count_bytes{4*count}; + + auto ref = &(buf[9]); + + 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 contents((std::istreambuf_iterator(stream)), std::istreambuf_iterator()); + + load_from_bytes(contents); + + return Status::OK; + } + + // from HesaiCorrection + nebula::Status save_to_file_from_bytes( + const std::string & calibration_file, const std::vector & 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 get_fov_padding() const override + { + // For FT120 should be enough + return {-0.1, 0.1}; + } +}; + /* @@ -808,4 +883,4 @@ inline bool supports_blockage_mask(const SensorModel & sensor_model) } // namespace drivers } // namespace nebula -#endif // NEBULA_HESAI_COMMON_H +#endif // NEBULA_HESAI_COMMON_H \ No newline at end of file diff --git a/src/nebula_hesai/nebula_hesai_decoders/calibration/PandarFT120.dat b/src/nebula_hesai/nebula_hesai_decoders/calibration/PandarFT120.dat new file mode 100644 index 0000000000000000000000000000000000000000..fb32214022f97b83788f4713758b1a37c200f2e7 GIT binary patch literal 153641 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 literal 0 HcmV?d00001 From 94b643d6c4aa69347c2113d9e7b0f100f5778e92 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 12:19:43 +0100 Subject: [PATCH 05/20] Added definitions for header, unit and data block. Added definitions for message tail, data packet and FT120 sensor. --- .../decoders/hesai_packet.hpp | 39 ++++++ .../decoders/pandar_ft120.hpp | 117 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp index 189791e8a..2036976be 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp @@ -89,6 +89,27 @@ struct SecondsSinceEpoch } }; +struct Header19B // FT120 +{ + // manual, 3.1.2.1 + /// @brief Start of Packet, 0xEEFF + uint16_t sop; + uint8_t protocol_major; + uint8_t protocol_minor; + uint8_t reserved1[2]; + + // manual, 3.1.2.2 + uint16_t column_num; // 160 + uint16_t row_num; // 120 + uint8_t column_res; // 63, to be multiplied by standard coefficient of 0.01° + uint8_t row_res; // 63, to be multiplied by standard coefficient of 0.01° + uint8_t return_num; // 0, single return, 1 dual return & block 1 returns first type of dual mode; 2 dual return & block 1 returns second type of dual mode; + uint8_t dis_unit; // 4, mm + uint8_t reserved2; + uint16_t block_row_num; // 120 + uint8_t reserved3[8]; +}; + struct Header12B { uint16_t sop; @@ -129,6 +150,13 @@ struct Unit4B uint8_t confidence_or_reserved; }; +struct Unit5B +{ + uint16_t distance; + uint8_t reflectivity; + uint16_t reserved; +}; + template struct Block { @@ -139,6 +167,17 @@ struct Block [[nodiscard]] uint32_t get_azimuth() const { return azimuth; } }; +template +struct NoAzimuthBlock +{ + UnitT units[UnitN]; + using unit_t = UnitT; + // FT120: azimuth came from angle correction file: each ray has its own angle + // uint32_t azimuth{0}; // do not add data into struct, or it changes data alignment + + [[nodiscard]] uint32_t get_azimuth() const { return 0; } +}; + template struct FineAzimuthBlock { diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp new file mode 100644 index 000000000..bd76a9da7 --- /dev/null +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp @@ -0,0 +1,117 @@ +// Copyright 2024 TIER IV, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "nebula_hesai_decoders/decoders/functional_safety.hpp" +#include "nebula_hesai_decoders/decoders/hesai_packet.hpp" +#include "nebula_hesai_decoders/decoders/hesai_sensor.hpp" + +#include + +namespace nebula::drivers +{ + +namespace hesai_packet +{ + +#pragma pack(push, 1) + +struct TailFT120 +{ + uint8_t reserved1[7]; + uint16_t column_id; + uint8_t frame_id; // counter, 0-255; incremented at each new scan + uint8_t reserved2; + uint8_t return_mode; + uint16_t frame_period; // 100, ms (sensor works @ 10Hz) + SecondsSinceEpoch date_time; + uint32_t timestamp; + uint8_t factory_information; // fixed, 0x42 + uint32_t udp_sequence; + uint32_t crc_tail; + uint32_t signature[4]; // packet AES signature, pre-header to crc_tail; 0, if no key set in sensor +}; + +struct PacketFT120 : public PacketBase<1, 120, 2, 160> // using degreeSubdivisions as the column count, to be supplied in AngleCorrectorCalibrationBasedSolidState +{ + using body_t = Body, PacketFT120::n_blocks>; // manual, 3.1.2.3 + Header19B header; + body_t body; + TailFT120 tail; // tail contains ColumnID value, used to identify the column of sensor readings inside the packet + + /* Ignored optional fields */ + // 3.1.3. Ethernet tail, 4 more bytes for frame check sequence + // uint8_t cyber_security[32]; +}; + +#pragma pack(pop) + +} // namespace hesai_packet + +class PandarFT120 : public +HesaiSensor +{ +private: + +public: + static constexpr float min_range = 0.05; + static constexpr float max_range = 25.0; + static constexpr int32_t col_N = 160; + static constexpr int32_t row_N = 120; + static constexpr size_t max_scan_buffer_points = col_N * row_N; + static constexpr FieldOfView fov_mdeg{{40'000, 140'000}, {-37'500, 37'500}}; + static constexpr AnglePair peak_resolution_mdeg{ + (fov_mdeg.azimuth.end - fov_mdeg.azimuth.start) / col_N, + (fov_mdeg.elevation.end - fov_mdeg.elevation.start) / row_N, + }; + + int get_packet_relative_point_time_offset( + uint32_t block_id, uint32_t channel_id, const packet_t & packet) override + { + // avoid warning "unused parameter" + (void) block_id; + (void) channel_id; + (void) packet; + + return 0; // all measurements are took at the same time + } + + ReturnType get_return_type( + hesai_packet::return_mode::ReturnMode return_mode, unsigned int return_idx, + const std::vector & return_units) override + { + // we could get info directly from packet contents: + // - return_mode is a copy of PandarFT120.tail.return_mode + // - return_idx is a copy of PandarFT120.header.return_num + + (void) return_units; + + switch (return_mode) { + case hesai_packet::return_mode::SINGLE_FIRST: + return ReturnType::FIRST; + case hesai_packet::return_mode::SINGLE_STRONGEST: + return ReturnType::STRONGEST; + + case hesai_packet::return_mode::DUAL_FIRST_STRONGEST: + // return_idx is 1 or 2 + return return_idx == 1 ? ReturnType::FIRST : ReturnType::STRONGEST; + + default: + return ReturnType::UNKNOWN; + } + } +}; + +} // namespace nebula::drivers From d7d4d26f357709d24dfd8f9e472b1eb23738678e Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 12:43:17 +0100 Subject: [PATCH 06/20] Added structures to manage sensor configuration and status. Renamed status structure that can be reused (is the same as for AT128 and QT128 sensors). Integrated the sensor in the HW interface. --- .../hesai_cmd_response.hpp | 127 +++++++++++++++++- .../src/hesai_hw_interface.cpp | 79 ++++++++--- 2 files changed, 180 insertions(+), 26 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp b/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp index 0bb2e2e4d..3ac0ab0ec 100644 --- a/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp +++ b/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp @@ -280,6 +280,8 @@ struct HesaiInventoryBase case 40: case 48: return "PandarAT128"; + case 120: + return "PandarFT120"; default: return "Unknown(" + std::to_string(static_cast(model)) + ")"; } @@ -447,6 +449,58 @@ struct HesaiInventory_AT128 : public HesaiInventoryBase Internal value; }; +struct HesaiInventory_FT120 : public HesaiInventoryBase +{ + struct Internal // : public HesaiInventoryBase::Internal + { + // byte 0-15: SN + char sn[18]; + // byte 19: model name, char --> 17-24 + empty bytes + char product_name[32]; + char date_of_manufacture[16]; // yyyy-mm-dd + 6 empty bytes + uint8_t mac[6]; + char sw_ver[16]; + char unknown[16]; // contains string "unused" + char hw_ver[16]; // firmware version + char unknown2[16]; // contains string "3" + char control_fw_ver[16]; + char sensor_fw_ver[16]; + char unknown3[16]; // contains string "51e19f60" + char unknown4[16]; // contains string "a3db47f7" + char unknown5[4]; + uint8_t product_model; // ex: 78; char: x; dec: 120! + char unknown6[11]; // contains string "x" in the middle + }; + + explicit HesaiInventory_FT120(Internal value) : value(value) { + std::memcpy(base.sn, value.sn, sizeof(base.sn)); + std::memcpy(base.date_of_manufacture, value.date_of_manufacture, sizeof(base.date_of_manufacture)); + std::memcpy(base.mac, value.mac, sizeof(base.mac)); + std::memcpy(base.sw_ver, value.sw_ver, sizeof(base.sw_ver)); + std::memcpy(base.hw_ver, value.hw_ver, sizeof(base.hw_ver)); + std::memcpy(base.control_fw_ver, value.control_fw_ver, sizeof(base.control_fw_ver)); + std::memcpy(base.sensor_fw_ver, value.sensor_fw_ver, sizeof(base.sensor_fw_ver)); + } + + [[nodiscard]] uint8_t model_number() const override { return value.product_model; } + + [[nodiscard]] const HesaiInventoryBase::Internal & get() const override { return base; } + + [[nodiscard]] ordered_json sensor_specifics_to_json() const override + { + ordered_json j; + j["product_name"] = value.product_name; + j["mac"] = value.mac; + j["model"] = get_str_model(value.product_model); + return j; + } + +private: + Internal value; + HesaiInventoryBase::Internal base; +}; + + /// @brief struct of PTC_COMMAND_GET_CONFIG_INFO struct HesaiConfigBase { @@ -566,6 +620,55 @@ struct HesaiConfig_OT128_AT128 : public HesaiConfigBase Internal value; }; +struct HesaiConfig_FT120 : public HesaiConfigBase +{ + struct Internal // structure does not begin like HesaiConfigBase::Internal + { + uint8_t ipaddr[4]; + uint8_t mask[4]; + uint8_t gateway[4]; + uint8_t dest_ipaddr[4]; + big_uint16_buf_t dest_LiDAR_udp_port; + uint8_t unknown1[4]; // here there are some numbers; their meaning is unknown... Samples: 126 39 0 (maybe sync_angle) 200 (maybe fake rpm) + uint8_t unknown2[10]; + uint8_t unknown3; + uint8_t unknown4[8]; + big_uint16_buf_t dest_gps_udp_port; // byte 41 + + // here the last four bytes can be used to simulate some rotating sensors standard parameters + big_uint16_buf_t spin_rate; + uint8_t motor_status; + uint8_t unknown5; + }; + + explicit HesaiConfig_FT120(Internal value) : value(value) { + + std::memcpy(base.ipaddr, value.ipaddr, sizeof(base.ipaddr)); + std::memcpy(base.mask, value.mask, sizeof(base.mask)); + std::memcpy(base.gateway, value.gateway, sizeof(base.gateway)); + std::memcpy(base.dest_ipaddr, value.dest_ipaddr, sizeof(base.dest_ipaddr)); + + base.dest_LiDAR_udp_port = value.dest_LiDAR_udp_port; + base.dest_gps_udp_port = value.dest_gps_udp_port; + base.spin_rate = 600; + base.motor_status = value.motor_status; + } + + [[nodiscard]] const HesaiConfigBase::Internal & get() const override { return base; } + + [[nodiscard]] ordered_json sensor_specifics_to_json() const override + { + ordered_json j; + return j; + } + +private: + Internal value; + // adding a HesaiConfigBase::Internal private structure to be used for the output of the get() method + HesaiConfigBase::Internal base; +}; + + struct HesaiConfig_XT_40P_64_QT128 : public HesaiConfigBase { struct Internal : public HesaiConfigBase::Internal @@ -668,7 +771,7 @@ inline std::ostream & operator<<(std::ostream & os, const HesaiLidarStatusBase & return os; } -struct HesaiLidarStatus_AT128_QT128 : public HesaiLidarStatusBase +struct HesaiLidarStatus_AT128_QT128_FT120 : public HesaiLidarStatusBase { struct Internal : public HesaiLidarStatusBase::Internal { @@ -700,7 +803,7 @@ struct HesaiLidarStatus_AT128_QT128 : public HesaiLidarStatusBase [[nodiscard]] const HesaiLidarStatusBase::Internal & get() const override { return value; } protected: - explicit HesaiLidarStatus_AT128_QT128(Internal value) : value(value) {} + explicit HesaiLidarStatus_AT128_QT128_FT120(Internal value) : value(value) {} [[nodiscard]] virtual std::array get_temperature_names() const = 0; @@ -708,9 +811,9 @@ struct HesaiLidarStatus_AT128_QT128 : public HesaiLidarStatusBase Internal value; }; -struct HesaiLidarStatusAT128 : public HesaiLidarStatus_AT128_QT128 +struct HesaiLidarStatusAT128 : public HesaiLidarStatus_AT128_QT128_FT120 { - explicit HesaiLidarStatusAT128(Internal value) : HesaiLidarStatus_AT128_QT128(value) {} + explicit HesaiLidarStatusAT128(Internal value) : HesaiLidarStatus_AT128_QT128_FT120(value) {} protected: [[nodiscard]] std::array get_temperature_names() const override @@ -723,9 +826,9 @@ struct HesaiLidarStatusAT128 : public HesaiLidarStatus_AT128_QT128 } }; -struct HesaiLidarStatusQT128 : public HesaiLidarStatus_AT128_QT128 +struct HesaiLidarStatusQT128 : public HesaiLidarStatus_AT128_QT128_FT120 { - explicit HesaiLidarStatusQT128(Internal value) : HesaiLidarStatus_AT128_QT128(value) {} + explicit HesaiLidarStatusQT128(Internal value) : HesaiLidarStatus_AT128_QT128_FT120(value) {} protected: [[nodiscard]] std::array get_temperature_names() const override @@ -796,6 +899,18 @@ struct HesaiLidarStatusOT128 : public HesaiLidarStatusBase Internal value; }; + +struct HesaiLidarStatusFT120 : public HesaiLidarStatus_AT128_QT128_FT120 +{ + explicit HesaiLidarStatusFT120(Internal value) : HesaiLidarStatus_AT128_QT128_FT120(value) { } + +protected: + [[nodiscard]] std::array get_temperature_names() const override + { + return {"", "", "", "", "", "", "", "", ""}; + } +}; + struct HesaiLidarStatus_XT_40p : public HesaiLidarStatusBase { struct Internal : public HesaiLidarStatusBase::Internal diff --git a/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp b/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp index f01c69f3b..22deeb37b 100644 --- a/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp +++ b/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp @@ -419,6 +419,10 @@ std::shared_ptr HesaiHwInterface::get_inventory() auto lidar_config = check_size_and_parse(response); return std::make_shared(lidar_config); } + case SensorModel::HESAI_PANDARFT120: { + auto lidar_config = check_size_and_parse(response); + return std::make_shared(lidar_config); + } } } @@ -443,6 +447,10 @@ std::shared_ptr HesaiHwInterface::get_config() auto lidar_config = check_size_and_parse(response); return std::make_shared(lidar_config); } + case SensorModel::HESAI_PANDARFT120: { + auto lidar_config = check_size_and_parse(response); + return std::make_shared(lidar_config); + } } } @@ -473,11 +481,19 @@ std::shared_ptr HesaiHwInterface::get_lidar_status() auto hesai_lidarstatus = check_size_and_parse(response); return std::make_shared(hesai_lidarstatus); } + case SensorModel::HESAI_PANDARFT120: { + auto hesai_lidarstatus = check_size_and_parse(response); + return std::make_shared(hesai_lidarstatus); + } } } Status HesaiHwInterface::set_spin_rate(uint16_t rpm) { + if (sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { + return Status::SENSOR_CONFIG_ERROR; + } + std::vector request_payload; request_payload.emplace_back((rpm >> 8) & 0xff); request_payload.emplace_back(rpm & 0xff); @@ -599,7 +615,8 @@ Status HesaiHwInterface::set_lidar_range(int start_ddeg, int end_ddeg) { if ( sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128 || - sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64) { + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64 || + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { return Status::SENSOR_CONFIG_ERROR; } @@ -623,7 +640,9 @@ HesaiLidarRangeAll HesaiHwInterface::get_lidar_range() { if ( sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128 || - sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64) { + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64 + || sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120 + ) { throw std::runtime_error("Not supported on this sensor"); } @@ -707,7 +726,8 @@ bool HesaiHwInterface::get_up_close_blockage_detection() Status HesaiHwInterface::check_and_set_lidar_range( const HesaiCalibrationConfigurationBase & calibration) { - if (sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128) { + if (sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128 + || sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { return Status::SENSOR_CONFIG_ERROR; } @@ -758,6 +778,12 @@ Status HesaiHwInterface::set_ptp_config( } std::vector request_payload; + + // On the FT120 there is always a reserved byte + if (sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { + request_payload.emplace_back(1 & 0xff); + } + request_payload.emplace_back(profile & 0xff); request_payload.emplace_back(domain & 0xff); request_payload.emplace_back(network & 0xff); @@ -874,6 +900,10 @@ Status HesaiHwInterface::send_reset() Status HesaiHwInterface::set_rot_dir(int mode) { + if (sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { + return Status::SENSOR_CONFIG_ERROR; + } + std::vector request_payload; request_payload.emplace_back(mode & 0xff); @@ -1120,24 +1150,27 @@ HesaiStatus HesaiHwInterface::check_and_set_config( std::this_thread::sleep_for(wait_time); } - auto current_rotation_speed = hesai_config.spin_rate; - if (sensor_configuration->rotation_speed != current_rotation_speed.value()) { - logger_->info( - "current lidar rotation_speed: " + - std::to_string(static_cast(current_rotation_speed.value()))); - logger_->info( - "current configuration rotation_speed: " + - std::to_string(sensor_configuration->rotation_speed)); - if (use_http_set_spin_rate()) { - set_spin_speed_async_http(sensor_configuration->rotation_speed); - } else { + // do not configure rotation for solid state sensors + if (sensor_configuration_->sensor_model != SensorModel::HESAI_PANDARFT120) { + auto current_rotation_speed = hesai_config.spin_rate; + if (sensor_configuration->rotation_speed != current_rotation_speed.value()) { logger_->info( - "Setting up spin rate via TCP." + std::to_string(sensor_configuration->rotation_speed)); - std::thread t( - [this, sensor_configuration] { set_spin_rate(sensor_configuration->rotation_speed); }); - t.join(); + "current lidar rotation_speed: " + + std::to_string(static_cast(current_rotation_speed.value()))); + logger_->info( + "current configuration rotation_speed: " + + std::to_string(sensor_configuration->rotation_speed)); + if (use_http_set_spin_rate()) { + set_spin_speed_async_http(sensor_configuration->rotation_speed); + } else { + logger_->info( + "Setting up spin rate via TCP." + std::to_string(sensor_configuration->rotation_speed)); + std::thread t( + [this, sensor_configuration] { set_spin_rate(sensor_configuration->rotation_speed); }); + t.join(); + } + std::this_thread::sleep_for(wait_time); } - std::this_thread::sleep_for(wait_time); } bool set_flg = false; @@ -1389,7 +1422,8 @@ HesaiStatus HesaiHwInterface::check_and_set_config() if ( sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128 || - sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64) { + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64 || + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { return Status::OK; } @@ -1419,6 +1453,7 @@ HesaiStatus HesaiHwInterface::check_and_set_config() 40: AT128? 42: OT128 48: ? +120: FT120 */ int HesaiHwInterface::nebula_model_to_hesai_model_no(nebula::drivers::SensorModel model) { @@ -1445,6 +1480,8 @@ int HesaiHwInterface::nebula_model_to_hesai_model_no(nebula::drivers::SensorMode return 42; case SensorModel::HESAI_PANDARAT128: return 48; + case SensorModel::HESAI_PANDARFT120: + return 120; // All other vendors and unknown sensors default: return -1; @@ -1475,6 +1512,7 @@ bool HesaiHwInterface::use_http_set_spin_rate(int model) case 38: case 42: case 48: + case 120: return false; } } @@ -1498,6 +1536,7 @@ bool HesaiHwInterface::use_http_get_lidar_monitor(int model) case 38: case 42: case 48: + case 120: return false; } } From ee41fc2e0b7ae39842a9f2bc0567d5f29e8406b5 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 12:46:22 +0100 Subject: [PATCH 07/20] Added the class that computes the lookup tables for azimuth and elevation for each pixel. --- ...orrector_calibration_based_solid_state.hpp | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp new file mode 100644 index 000000000..6cc95ab47 --- /dev/null +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp @@ -0,0 +1,120 @@ +// Copyright 2024 TIER IV, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "nebula_core_decoders/angles.hpp" +#include "nebula_hesai_common/hesai_common.hpp" +#include "nebula_hesai_decoders/decoders/angle_corrector.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace nebula::drivers +{ + +template +class AngleCorrectorCalibrationBasedSolidState : public AngleCorrector +{ +private: + std::array, ColumnN> correctedAngleData; + +public: + + explicit AngleCorrectorCalibrationBasedSolidState( + const std::shared_ptr & sensor_calibration, + double fov_start_azimuth_deg, double fov_end_azimuth_deg, double scan_cut_azimuth_deg) + { + // not used parameters + (void) fov_start_azimuth_deg; + (void) fov_end_azimuth_deg; + (void) scan_cut_azimuth_deg; + + if (sensor_calibration == nullptr) { + throw std::runtime_error( + "Cannot instantiate AngleCorrectorCalibrationBasedSolidState without calibration data"); + } + + // //////////////////////////////////////// + // Build lookup table + // //////////////////////////////////////// + + size_t calib_i = 0; + + const double res_coeff = 0.01 * sensor_calibration->resolution * M_PI / 180.; // also, convert to rad + + for (size_t j = 0; j < ColumnN; j++) // column + { + for (size_t i = 0; i < RowN; i++) // row + { + // calibration vectors contain column-major ordered elevation and azimuth angles in degrees, + // in "cartesian order": + // - first value is lower left sensor point; + // - first column last point is top left sensor point; + // - last column first point is lower right sensor point; + // - last column last point is top right sensor point; + // Note: azimuth has a value of 0° when the point lies in the plane X=0 (so + // to get correct point coordinates, sine and cosine have to be used correctly, + // as described in user manual. See hesai_decoder.hpp for the implementation) + const double azi = sensor_calibration->azimuth_adjust.at(calib_i) * res_coeff ; + const double ele = sensor_calibration->elevation_adjust.at(calib_i) * res_coeff; + + ++calib_i; + + auto C = CorrectedAngleData(); + + C.azimuth_rad = static_cast(azi); + C.elevation_rad = static_cast(ele); + C.sin_azimuth = static_cast(sin(azi)); + C.cos_azimuth = static_cast(cos(azi)); + C.sin_elevation = static_cast(sin(ele)); + C.cos_elevation = static_cast(cos(ele)); + + correctedAngleData[j][i] = C; + } + } + } + + [[nodiscard]] CorrectedAngleData get_corrected_angle_data(uint32_t row_id, uint32_t col_id) const + { + return correctedAngleData[col_id][row_id]; + } + + // this base method is not used for solid state sensor, as all angles came from get_corrected_angle_data + [[nodiscard]] CorrectedAzimuths get_corrected_azimuths(uint32_t block_azimuth) const + { + // not used parameters + (void) block_azimuth; + return CorrectedAzimuths(); + }; + + static bool passed_emit_angle(uint32_t last_azimuth, uint32_t current_azimuth) + { + return last_azimuth > current_azimuth; + } + + static bool passed_timestamp_reset_angle(uint32_t last_azimuth, uint32_t current_azimuth) + { + return last_azimuth > current_azimuth; + } +}; + +} // namespace nebula::drivers From 72e5b07aacaa6a5b9877fd6533f656b2a1d3d4a8 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 12:54:30 +0100 Subject: [PATCH 08/20] Integratedthe new angle corrector into HesaiSensor. --- .../decoders/hesai_sensor.hpp | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp index c4c990363..b6cf36683 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp @@ -18,6 +18,7 @@ #include "nebula_core_decoders/point_filters/downsample_mask.hpp" #include "nebula_hesai_decoders/decoders/angle_corrector_calibration_based.hpp" #include "nebula_hesai_decoders/decoders/angle_corrector_correction_based.hpp" +#include "nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp" #include "nebula_hesai_decoders/decoders/hesai_packet.hpp" #include @@ -29,7 +30,10 @@ namespace nebula::drivers { -enum class AngleCorrectionType { CALIBRATION, CORRECTION }; +enum class AngleCorrectionType { CALIBRATION = 0, CORRECTION, SOLIDSTATE }; + +template +using static_switch = typename std::tuple_element >::type; /// @brief Base class for all sensor definitions /// @tparam PacketT The packet type of the sensor @@ -89,10 +93,12 @@ class HesaiSensor public: using packet_t = PacketT; - using angle_corrector_t = typename std::conditional< - (AngleCorrection == AngleCorrectionType::CALIBRATION), - AngleCorrectorCalibrationBased, - AngleCorrectorCorrectionBased>::type; + + using angle_corrector_t = static_switch< + static_cast(AngleCorrection), + AngleCorrectorCalibrationBased, // CALIBRATION + AngleCorrectorCorrectionBased, // CORRECTION + AngleCorrectorCalibrationBasedSolidState>; // SOLIDSTATE; degree_subdivisions represent the sensor column count HesaiSensor() = default; virtual ~HesaiSensor() = default; From 57afc90e07af4765d036eece81ceff001c2340b8 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 16:03:55 +0100 Subject: [PATCH 09/20] Added the decoder class for solid state sensor. Some notes: - it uses old ScanCutAngles approac (not a big deal due to limited FoV); - dual return works, but manual recommends to use only single return modes; - maybe a couple of "if" in unpack and convert_returns can be simplified; -mask filter was not tested. --- .../decoders/hesai_decoder.hpp | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp index 78a74ee0a..481cc7057 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp @@ -396,4 +396,401 @@ class HesaiDecoder : public HesaiScanDecoder } }; + +template +class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor +{ +private: + struct ScanCutAngles + { + float fov_min; + float fov_max; + float scan_emit_angle; + }; + + struct DecodeFrame + { + NebulaPointCloudPtr pointcloud; + uint64_t scan_timestamp_ns{0}; + std::optional blockage_mask; + }; + + /// @brief Configuration for this decoder + const std::shared_ptr sensor_configuration_; + + /// @brief The sensor definition, used for return mode and time offset handling + SensorT sensor_{}; + + /// @brief A function that is called on each decoded pointcloud frame + pointcloud_callback_t pointcloud_callback_; + + /// @brief Decodes azimuth/elevation angles given calibration/correction data + typename SensorT::angle_corrector_t angle_corrector_; + + /// @brief Decodes functional safety data for supported sensors + std::shared_ptr> + functional_safety_decoder_; + + std::shared_ptr> packet_loss_detector_; + + /// @brief The last decoded packet + typename SensorT::packet_t packet_; + /// @brief The previous decoded packet (for dual return) + typename SensorT::packet_t previous_packet_; + + ScanCutAngles scan_cut_angles_; + uint32_t last_azimuth_id_ = 0; + + std::shared_ptr logger_; + + std::optional mask_filter_; + + std::shared_ptr blockage_mask_plugin_; + + /// @brief Decoded data of the frame currently being decoded to + DecodeFrame decode_frame_; + /// @brief Decoded data of the frame currently being output + DecodeFrame output_frame_; + + /// @brief Validates and parse PandarPacket. Checks size and, if present, CRC checksums. + /// @param packet The incoming PandarPacket + /// @return Whether the packet was parsed successfully + bool parse_packet(const std::vector & packet) + { + if (packet.size() < sizeof(typename SensorT::packet_t)) { + NEBULA_LOG_STREAM( + logger_->error, "Packet size mismatch: " << packet.size() << " | Expected at least: " + << sizeof(typename SensorT::packet_t)); + return false; + } + + if (!std::memcpy(&packet_, packet.data(), sizeof(typename SensorT::packet_t))) { + logger_->error("Packet memcopy failed"); + return false; + } + + return true; + } + + /// @brief Converts a group of returns (i.e. 1 for single return, 2 for dual return, etc.) to + /// points and appends them to the point cloud + /// @param start_block_id The first block in the group of returns + /// @param n_blocks The number of returns in the group (has to align with the `n_returns` field in + /// the packet footer) + void convert_returns(size_t start_block_id, size_t n_returns) + { + (void) start_block_id; + + uint64_t packet_timestamp_ns = hesai_packet::get_timestamp_ns(packet_); + uint32_t column_id = packet_.tail.column_id; + + std::vector return_units; + + // If the blockage mask plugin is not present, we can return early if distance checks fail + const bool filters_can_return_early = !blockage_mask_plugin_; + + const unsigned int return_idx = packet_.header.return_num; + + const auto return_type = sensor_.get_return_type( + static_cast(packet_.tail.return_mode), + return_idx, return_units); + + // dual return: store current packet and wait for the 2nd + if (n_returns == 2 && return_idx == 1 ) { + std::swap(packet_, previous_packet_); + return; + } + + for (size_t row_id = 0; row_id < SensorT::packet_t::n_channels; ++row_id) { + // For FT120, a "channel" is exactly one full column of readings from the sensor; + // only 1 return group is sent in a packet + + // Find the units corresponding to the same return group as the current one. + // These are used to find duplicates in multi-return mode. + return_units.clear(); + + return_units.push_back( + &packet_.body.blocks[0].units[row_id]); + + // eventually, get the first return from the previous packet + if (return_idx == 2 ) { + return_units.push_back( + &previous_packet_.body.blocks[0].units[row_id]); + } + + const CorrectedAngleData corrected_angle_data = + angle_corrector_.get_corrected_angle_data(row_id, column_id); + + for (size_t block_offset = 0; block_offset < n_returns; ++block_offset) { + auto & unit = *return_units[block_offset]; + + bool point_is_valid = true; + + if (unit.distance == 0) { + point_is_valid = false; + } + + float distance = get_distance(unit); + + if ( + distance < SensorT::min_range || SensorT::max_range < distance || + distance < sensor_configuration_->min_range || + sensor_configuration_->max_range < distance) { + point_is_valid = false; + } + + // the second return is transmitted using the following block, so in order to remove duplicated points, + // we should compare distance between points in this packet and in the previus one + // Keep only last (if any) of multiple points that are too close + if (block_offset != n_returns - 1) { + bool is_below_multi_return_threshold = false; + + for (size_t return_idx = 0; return_idx < n_returns; ++return_idx) { + if (return_idx == block_offset) { + continue; + } + + if ( + fabsf(get_distance(*return_units[return_idx]) - distance) < + sensor_configuration_->dual_return_distance_threshold) { + is_below_multi_return_threshold = true; + break; + } + } + + if (is_below_multi_return_threshold) { + point_is_valid = false; + } + } + + if (filters_can_return_early && !point_is_valid) { + continue; + } + + float azimuth = corrected_angle_data.azimuth_rad; + + const bool in_fov = angle_is_between(scan_cut_angles_.fov_min, scan_cut_angles_.fov_max, azimuth); + if (!in_fov) { + continue; + } + + bool in_current_scan = true; + + auto & frame = in_current_scan ? decode_frame_ : output_frame_; + + if (frame.blockage_mask) { + frame.blockage_mask->update( + azimuth, row_id, sensor_.get_blockage_type(unit.distance)); + } + + if (!point_is_valid) { + continue; + } + + NebulaPoint point; + point.distance = distance; + point.intensity = unit.reflectivity; + point.time_stamp = packet_timestamp_ns - frame.scan_timestamp_ns; + + point.return_type = static_cast(return_type); + point.channel = row_id; + + // Use sin/cos functions from calibration data from corrected_angle_data + const float xy_distance = distance * corrected_angle_data.cos_elevation; + point.x = xy_distance * corrected_angle_data.sin_azimuth; + point.y = xy_distance * corrected_angle_data.cos_azimuth; + point.z = distance * corrected_angle_data.sin_elevation; + + // The driver wrapper converts to degrees, expects radians + point.azimuth = corrected_angle_data.azimuth_rad; + point.elevation = corrected_angle_data.elevation_rad; + + if (!mask_filter_ || !mask_filter_->excluded(point)) { + frame.pointcloud->emplace_back(point); + } + } + } + } + + /// @brief Get the distance of the given unit in meters + float get_distance(const typename SensorT::packet_t::body_t::block_t::unit_t & unit) + { + return unit.distance * hesai_packet::get_dis_unit(packet_); + } + + /// @brief Get timestamp of point in nanoseconds, relative to scan timestamp. Includes firing time + /// offset correction for channel and block + /// @param scan_timestamp_ns Start timestamp of the current scan in nanoseconds + /// @param packet_timestamp_ns The timestamp of the current PandarPacket in nanoseconds + /// @param block_id The block index of the point + /// @param channel_id The channel index of the point + uint32_t get_point_time_relative( + uint64_t scan_timestamp_ns, uint64_t packet_timestamp_ns, size_t block_id, size_t channel_id) + { + (void) block_id; + (void) channel_id; + + // this is a flash solid state LIDAR, point_to_packet_offset_ns is 0 as measurements comes from the same light emission and + // there is non need to correct packet_to_scan_offset_ns + auto packet_to_scan_offset_ns = static_cast(packet_timestamp_ns - scan_timestamp_ns); + return packet_to_scan_offset_ns; + } + + DecodeFrame initialize_frame() const + { + DecodeFrame frame = {std::make_shared(), 0, std::nullopt}; + frame.pointcloud->reserve(SensorT::max_scan_buffer_points); + + if (blockage_mask_plugin_) { + frame.blockage_mask = point_filters::BlockageMask( + SensorT::fov_mdeg.azimuth, blockage_mask_plugin_->get_bin_width_mdeg(), + SensorT::packet_t::n_channels); + } + + return frame; + } + + /// @brief Called when a scan is complete, published and then clears the output frame. + void on_scan_complete() + { + double scan_timestamp_s = static_cast(output_frame_.scan_timestamp_ns) * 1e-9; + + if (pointcloud_callback_) { + pointcloud_callback_(output_frame_.pointcloud, scan_timestamp_s); + } + + if (blockage_mask_plugin_ && output_frame_.blockage_mask) { + blockage_mask_plugin_->callback_and_reset( + output_frame_.blockage_mask.value(), scan_timestamp_s); + } + + output_frame_.pointcloud->clear(); + } + +public: + /// @brief Constructor + /// @param sensor_configuration SensorConfiguration for this decoder + /// @param correction_data Calibration data for this decoder + explicit HesaiSolidStateDecoder( + const std::shared_ptr & sensor_configuration, + const std::shared_ptr & + correction_data, + const std::shared_ptr & logger, + const std::shared_ptr> & + functional_safety_decoder, + const std::shared_ptr> & + packet_loss_detector, + std::shared_ptr blockage_mask_plugin) + : sensor_configuration_(sensor_configuration), + angle_corrector_( + correction_data, sensor_configuration_->cloud_min_angle, + sensor_configuration_->cloud_max_angle, sensor_configuration_->cut_angle), + functional_safety_decoder_(functional_safety_decoder), + packet_loss_detector_(packet_loss_detector), + scan_cut_angles_( + {static_cast(deg2rad(sensor_configuration_->cloud_min_angle)), + static_cast(deg2rad(sensor_configuration_->cloud_max_angle)), + static_cast(deg2rad(sensor_configuration_->cut_angle))}), + logger_(logger), + blockage_mask_plugin_(std::move(blockage_mask_plugin)), + decode_frame_(initialize_frame()), + output_frame_(initialize_frame()) + { + if (sensor_configuration->downsample_mask_path) { + mask_filter_ = point_filters::DownsampleMaskFilter( + sensor_configuration->downsample_mask_path.value(), SensorT::fov_mdeg.azimuth, + SensorT::peak_resolution_mdeg.azimuth, SensorT::packet_t::n_channels, + logger_->child("Downsample Mask"), true, sensor_.get_dither_transform()); + } + } + + void set_pointcloud_callback(pointcloud_callback_t callback) override + { + pointcloud_callback_ = std::move(callback); + } + + PacketDecodeResult unpack(const std::vector & packet) override + { + util::Stopwatch decode_watch; + + if (!parse_packet(packet)) { + return {PerformanceCounters{decode_watch.elapsed_ns()}, DecodeError::PACKET_PARSE_FAILED}; + } + if (packet_loss_detector_) { + packet_loss_detector_->update(packet_); + } + + // Even if the checksums of other parts of the packet are invalid, functional safety info + // is still checked. This is a null-op for sensors that do not support functional safety. + if (functional_safety_decoder_) { + functional_safety_decoder_->update(packet_); + } + + // FYI: This is where the CRC would be checked. Since this caused performance issues in the + // past, and since the frame check sequence of the packet is already checked by the NIC, we skip + // it here. + + // This is the first scan, set scan timestamp to whatever packet arrived first + // It is valid for a flash LIDAR sensor as the FT120 + if (decode_frame_.scan_timestamp_ns == 0) { + decode_frame_.scan_timestamp_ns = + hesai_packet::get_timestamp_ns(packet_); + } + + bool did_scan_complete = false; + + const size_t n_returns = hesai_packet::get_n_returns(packet_.tail.return_mode); + const size_t block_id = 0; + const auto block_column_id = packet_.tail.column_id; + + // We have a new scan when new azimut (block_column_id) go back to first column + if (angle_corrector_.passed_timestamp_reset_angle(last_azimuth_id_, block_column_id)) { + uint64_t new_scan_timestamp_ns = + hesai_packet::get_timestamp_ns(packet_); + + // Check FT120: it should always go into the "else" branch + if (sensor_configuration_->cut_angle == sensor_configuration_->cloud_max_angle) { + // In the non-360 deg case, if the cut angle and FoV end coincide, the old pointcloud has + // already been swapped and published before the timestamp reset angle is reached. Thus, + // the `decode` pointcloud is now empty and will be decoded to. Reset its timestamp. + decode_frame_.scan_timestamp_ns = new_scan_timestamp_ns; + decode_frame_.pointcloud->clear(); + } else { + // When not cutting at the end of the FoV (i.e. the FoV is 360 deg or a cut occurs + // somewhere within a non-360 deg FoV), the current scan is still being decoded to the + // `decode` pointcloud but at the same time, points for the next pointcloud are arriving + // and will be decoded to the `output` pointcloud (please forgive the naming for now). + // Thus, reset the output pointcloud's timestamp. + output_frame_.scan_timestamp_ns = new_scan_timestamp_ns; + } + } + + convert_returns(block_id, n_returns); + + if (angle_corrector_.passed_emit_angle(last_azimuth_id_, block_column_id)) { + // The current `decode` pointcloud is ready for publishing, swap buffers to continue with + // the `output` pointcloud as the `decode` pointcloud. + std::swap(decode_frame_, output_frame_); + did_scan_complete = true; + } + + last_azimuth_id_ = block_column_id; + + uint64_t decode_duration_ns = decode_watch.elapsed_ns(); + uint64_t callbacks_duration_ns = 0; + + if (did_scan_complete) { + util::Stopwatch callback_watch; + on_scan_complete(); + callbacks_duration_ns += callback_watch.elapsed_ns(); + } + + PacketMetadata metadata; + metadata.packet_timestamp_ns = hesai_packet::get_timestamp_ns(packet_); + metadata.did_scan_complete = did_scan_complete; + return {PerformanceCounters{decode_duration_ns - callbacks_duration_ns}, metadata}; + } +}; + + } // namespace nebula::drivers From 30a841c95b12ba208a24c42d43559c8e5d6e1a94 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 25 Feb 2026 16:14:00 +0100 Subject: [PATCH 10/20] Include the solid state calibration in the ROS wrapper. Include the new sensor in the driver source, with the correct decoder selection. --- .../nebula_hesai/src/hesai_ros_wrapper.cpp | 5 ++++- .../include/nebula_hesai_decoders/hesai_driver.hpp | 6 ++++-- .../nebula_hesai_decoders/src/hesai_driver.cpp | 12 +++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai/src/hesai_ros_wrapper.cpp b/src/nebula_hesai/nebula_hesai/src/hesai_ros_wrapper.cpp index 5f92f2407..faf04b584 100644 --- a/src/nebula_hesai/nebula_hesai/src/hesai_ros_wrapper.cpp +++ b/src/nebula_hesai/nebula_hesai/src/hesai_ros_wrapper.cpp @@ -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 = @@ -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(); + } else if (sensor_cfg_ptr_->sensor_model == drivers::SensorModel::HESAI_PANDARFT120) { + calib = std::make_shared(); } else { calib = std::make_shared(); } diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/hesai_driver.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/hesai_driver.hpp index 49b94302b..151670d4c 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/hesai_driver.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/hesai_driver.hpp @@ -108,8 +108,10 @@ class HesaiDriver HesaiDriver() = delete; /// @brief Constructor /// @param sensor_configuration SensorConfiguration for this driver - /// @param calibration_configuration CalibrationConfiguration for this driver (either - /// HesaiCalibrationConfiguration for sensors other than AT128 or HesaiCorrection for AT128) + /// @param calibration_configuration CalibrationConfiguration for this driver ( + /// HesaiCalibrationConfiguration for rotating sensors other than AT128 or + /// HesaiCorrection for AT128 or + /// HesaiSolidStateCalibration for FT120 ) HesaiDriver( const std::shared_ptr & sensor_configuration, const std::shared_ptr & diff --git a/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp b/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp index adbf2bfa6..f5c00dea0 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp +++ b/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp @@ -10,6 +10,7 @@ #include "nebula_hesai_decoders/decoders/pandar_40.hpp" #include "nebula_hesai_decoders/decoders/pandar_64.hpp" #include "nebula_hesai_decoders/decoders/pandar_at128.hpp" +#include "nebula_hesai_decoders/decoders/pandar_ft120.hpp" #include "nebula_hesai_decoders/decoders/pandar_qt128.hpp" #include "nebula_hesai_decoders/decoders/pandar_qt64.hpp" #include "nebula_hesai_decoders/decoders/pandar_xt16.hpp" @@ -83,6 +84,12 @@ HesaiDriver::HesaiDriver( std::move(blockage_mask_plugin)); break; } + case SensorModel::HESAI_PANDARFT120: { + scan_decoder_ = initialize_decoder( + sensor_configuration, calibration_data, alive_cb, stuck_cb, status_cb, lost_cb, + std::move(blockage_mask_plugin)); + break; + } case SensorModel::UNKNOWN: driver_status_ = nebula::Status::INVALID_SENSOR_MODEL; throw std::runtime_error("Invalid sensor model."); @@ -110,7 +117,10 @@ std::shared_ptr HesaiDriver::initialize_decoder( auto packet_loss_detector = initialize_packet_loss_detector(lost_cb); using CalibT = typename SensorT::angle_corrector_t::correction_data_t; - return std::make_shared>( + + using HesaiDecoderTp = typename std::conditional<(std::is_same::value), HesaiSolidStateDecoder, HesaiDecoder>::type; + + return std::make_shared( sensor_configuration, std::dynamic_pointer_cast(calibration_configuration), logger_->child("Decoder"), functional_safety_decoder, packet_loss_detector, std::move(blockage_mask_plugin)); From 94f3387ee6cefad53bfc4084cbfb725149f89469 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:17:08 +0000 Subject: [PATCH 11/20] ci(pre-commit): autofix --- .../nebula_hesai_common/hesai_common.hpp | 13 ++--- ...orrector_calibration_based_solid_state.hpp | 23 +++++---- .../decoders/hesai_decoder.hpp | 50 ++++++++----------- .../decoders/hesai_packet.hpp | 11 ++-- .../decoders/hesai_sensor.hpp | 15 +++--- .../decoders/pandar_ft120.hpp | 34 +++++++------ .../src/hesai_driver.cpp | 4 +- .../hesai_cmd_response.hpp | 41 +++++++-------- .../src/hesai_hw_interface.cpp | 10 ++-- 9 files changed, 105 insertions(+), 96 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp index 70d4d130f..21969704b 100644 --- a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp +++ b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp @@ -594,7 +594,7 @@ struct HesaiCorrection : public HesaiCalibrationConfigurationBase /// @brief struct for Hesai correction configuration for solid state sensors (for FT120) struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase { - public: +public: std::vector azimuth_adjust; std::vector elevation_adjust; @@ -610,8 +610,8 @@ struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase row_count = raw_ptr[7]; resolution = raw_ptr[8]; - const auto count{col_count*row_count}; - const auto count_bytes{4*count}; + const auto count{col_count * row_count}; + const auto count_bytes{4 * count}; auto ref = &(buf[9]); @@ -619,7 +619,7 @@ struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase std::memcpy(azimuth_adjust.data(), ref, count_bytes); elevation_adjust.resize(count); - std::memcpy(elevation_adjust.data(), ref + count_bytes , count_bytes); + std::memcpy(elevation_adjust.data(), ref + count_bytes, count_bytes); return Status::OK; } @@ -627,7 +627,8 @@ struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase 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 contents((std::istreambuf_iterator(stream)), std::istreambuf_iterator()); + std::vector contents( + (std::istreambuf_iterator(stream)), std::istreambuf_iterator()); load_from_bytes(contents); @@ -883,4 +884,4 @@ inline bool supports_blockage_mask(const SensorModel & sensor_model) } // namespace drivers } // namespace nebula -#endif // NEBULA_HESAI_COMMON_H \ No newline at end of file +#endif // NEBULA_HESAI_COMMON_H diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp index 6cc95ab47..7323b28f3 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp @@ -32,21 +32,21 @@ namespace nebula::drivers { template -class AngleCorrectorCalibrationBasedSolidState : public AngleCorrector +class AngleCorrectorCalibrationBasedSolidState +: public AngleCorrector { private: std::array, ColumnN> correctedAngleData; public: - explicit AngleCorrectorCalibrationBasedSolidState( const std::shared_ptr & sensor_calibration, double fov_start_azimuth_deg, double fov_end_azimuth_deg, double scan_cut_azimuth_deg) { // not used parameters - (void) fov_start_azimuth_deg; - (void) fov_end_azimuth_deg; - (void) scan_cut_azimuth_deg; + (void)fov_start_azimuth_deg; + (void)fov_end_azimuth_deg; + (void)scan_cut_azimuth_deg; if (sensor_calibration == nullptr) { throw std::runtime_error( @@ -59,7 +59,8 @@ class AngleCorrectorCalibrationBasedSolidState : public AngleCorrectorresolution * M_PI / 180.; // also, convert to rad + const double res_coeff = + 0.01 * sensor_calibration->resolution * M_PI / 180.; // also, convert to rad for (size_t j = 0; j < ColumnN; j++) // column { @@ -74,7 +75,7 @@ class AngleCorrectorCalibrationBasedSolidState : public AngleCorrectorazimuth_adjust.at(calib_i) * res_coeff ; + const double azi = sensor_calibration->azimuth_adjust.at(calib_i) * res_coeff; const double ele = sensor_calibration->elevation_adjust.at(calib_i) * res_coeff; ++calib_i; @@ -98,11 +99,13 @@ class AngleCorrectorCalibrationBasedSolidState : public AngleCorrector get_corrected_azimuths(uint32_t block_azimuth) const + // this base method is not used for solid state sensor, as all angles came from + // get_corrected_angle_data + [[nodiscard]] CorrectedAzimuths get_corrected_azimuths( + uint32_t block_azimuth) const { // not used parameters - (void) block_azimuth; + (void)block_azimuth; return CorrectedAzimuths(); }; diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp index 481cc7057..e02410a89 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp @@ -396,9 +396,8 @@ class HesaiDecoder : public HesaiScanDecoder } }; - template -class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor +class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor { private: struct ScanCutAngles @@ -479,10 +478,10 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor /// the packet footer) void convert_returns(size_t start_block_id, size_t n_returns) { - (void) start_block_id; + (void)start_block_id; uint64_t packet_timestamp_ns = hesai_packet::get_timestamp_ns(packet_); - uint32_t column_id = packet_.tail.column_id; + uint32_t column_id = packet_.tail.column_id; std::vector return_units; @@ -492,11 +491,11 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor const unsigned int return_idx = packet_.header.return_num; const auto return_type = sensor_.get_return_type( - static_cast(packet_.tail.return_mode), - return_idx, return_units); + static_cast(packet_.tail.return_mode), return_idx, + return_units); // dual return: store current packet and wait for the 2nd - if (n_returns == 2 && return_idx == 1 ) { + if (n_returns == 2 && return_idx == 1) { std::swap(packet_, previous_packet_); return; } @@ -509,17 +508,15 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor // These are used to find duplicates in multi-return mode. return_units.clear(); - return_units.push_back( - &packet_.body.blocks[0].units[row_id]); + return_units.push_back(&packet_.body.blocks[0].units[row_id]); // eventually, get the first return from the previous packet - if (return_idx == 2 ) { - return_units.push_back( - &previous_packet_.body.blocks[0].units[row_id]); + if (return_idx == 2) { + return_units.push_back(&previous_packet_.body.blocks[0].units[row_id]); } const CorrectedAngleData corrected_angle_data = - angle_corrector_.get_corrected_angle_data(row_id, column_id); + angle_corrector_.get_corrected_angle_data(row_id, column_id); for (size_t block_offset = 0; block_offset < n_returns; ++block_offset) { auto & unit = *return_units[block_offset]; @@ -539,9 +536,9 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor point_is_valid = false; } - // the second return is transmitted using the following block, so in order to remove duplicated points, - // we should compare distance between points in this packet and in the previus one - // Keep only last (if any) of multiple points that are too close + // the second return is transmitted using the following block, so in order to remove + // duplicated points, we should compare distance between points in this packet and in the + // previus one Keep only last (if any) of multiple points that are too close if (block_offset != n_returns - 1) { bool is_below_multi_return_threshold = false; @@ -569,7 +566,8 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor float azimuth = corrected_angle_data.azimuth_rad; - const bool in_fov = angle_is_between(scan_cut_angles_.fov_min, scan_cut_angles_.fov_max, azimuth); + const bool in_fov = + angle_is_between(scan_cut_angles_.fov_min, scan_cut_angles_.fov_max, azimuth); if (!in_fov) { continue; } @@ -579,8 +577,7 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor auto & frame = in_current_scan ? decode_frame_ : output_frame_; if (frame.blockage_mask) { - frame.blockage_mask->update( - azimuth, row_id, sensor_.get_blockage_type(unit.distance)); + frame.blockage_mask->update(azimuth, row_id, sensor_.get_blockage_type(unit.distance)); } if (!point_is_valid) { @@ -627,11 +624,11 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor uint32_t get_point_time_relative( uint64_t scan_timestamp_ns, uint64_t packet_timestamp_ns, size_t block_id, size_t channel_id) { - (void) block_id; - (void) channel_id; + (void)block_id; + (void)channel_id; - // this is a flash solid state LIDAR, point_to_packet_offset_ns is 0 as measurements comes from the same light emission and - // there is non need to correct packet_to_scan_offset_ns + // this is a flash solid state LIDAR, point_to_packet_offset_ns is 0 as measurements comes from + // the same light emission and there is non need to correct packet_to_scan_offset_ns auto packet_to_scan_offset_ns = static_cast(packet_timestamp_ns - scan_timestamp_ns); return packet_to_scan_offset_ns; } @@ -733,8 +730,7 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor // This is the first scan, set scan timestamp to whatever packet arrived first // It is valid for a flash LIDAR sensor as the FT120 if (decode_frame_.scan_timestamp_ns == 0) { - decode_frame_.scan_timestamp_ns = - hesai_packet::get_timestamp_ns(packet_); + decode_frame_.scan_timestamp_ns = hesai_packet::get_timestamp_ns(packet_); } bool did_scan_complete = false; @@ -745,8 +741,7 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor // We have a new scan when new azimut (block_column_id) go back to first column if (angle_corrector_.passed_timestamp_reset_angle(last_azimuth_id_, block_column_id)) { - uint64_t new_scan_timestamp_ns = - hesai_packet::get_timestamp_ns(packet_); + uint64_t new_scan_timestamp_ns = hesai_packet::get_timestamp_ns(packet_); // Check FT120: it should always go into the "else" branch if (sensor_configuration_->cut_angle == sensor_configuration_->cloud_max_angle) { @@ -792,5 +787,4 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State sensor } }; - } // namespace nebula::drivers diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp index 2036976be..2c1c3caa8 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp @@ -100,13 +100,14 @@ struct Header19B // FT120 // manual, 3.1.2.2 uint16_t column_num; // 160 - uint16_t row_num; // 120 + uint16_t row_num; // 120 uint8_t column_res; // 63, to be multiplied by standard coefficient of 0.01° - uint8_t row_res; // 63, to be multiplied by standard coefficient of 0.01° - uint8_t return_num; // 0, single return, 1 dual return & block 1 returns first type of dual mode; 2 dual return & block 1 returns second type of dual mode; - uint8_t dis_unit; // 4, mm + uint8_t row_res; // 63, to be multiplied by standard coefficient of 0.01° + uint8_t return_num; // 0, single return, 1 dual return & block 1 returns first type of dual mode; + // 2 dual return & block 1 returns second type of dual mode; + uint8_t dis_unit; // 4, mm uint8_t reserved2; - uint16_t block_row_num; // 120 + uint16_t block_row_num; // 120 uint8_t reserved3[8]; }; diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp index b6cf36683..8983374c1 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp @@ -17,8 +17,8 @@ #include "nebula_core_decoders/point_filters/blockage_mask.hpp" #include "nebula_core_decoders/point_filters/downsample_mask.hpp" #include "nebula_hesai_decoders/decoders/angle_corrector_calibration_based.hpp" -#include "nebula_hesai_decoders/decoders/angle_corrector_correction_based.hpp" #include "nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp" +#include "nebula_hesai_decoders/decoders/angle_corrector_correction_based.hpp" #include "nebula_hesai_decoders/decoders/hesai_packet.hpp" #include @@ -32,8 +32,8 @@ namespace nebula::drivers enum class AngleCorrectionType { CALIBRATION = 0, CORRECTION, SOLIDSTATE }; -template -using static_switch = typename std::tuple_element >::type; +template +using static_switch = typename std::tuple_element>::type; /// @brief Base class for all sensor definitions /// @tparam PacketT The packet type of the sensor @@ -96,9 +96,12 @@ class HesaiSensor using angle_corrector_t = static_switch< static_cast(AngleCorrection), - AngleCorrectorCalibrationBased, // CALIBRATION - AngleCorrectorCorrectionBased, // CORRECTION - AngleCorrectorCalibrationBasedSolidState>; // SOLIDSTATE; degree_subdivisions represent the sensor column count + AngleCorrectorCalibrationBased< + PacketT::n_channels, PacketT::degree_subdivisions>, // CALIBRATION + AngleCorrectorCorrectionBased, // CORRECTION + AngleCorrectorCalibrationBasedSolidState< + PacketT::n_channels, PacketT::degree_subdivisions>>; // SOLIDSTATE; degree_subdivisions + // represent the sensor column count HesaiSensor() = default; virtual ~HesaiSensor() = default; diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp index bd76a9da7..90339d1a0 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp @@ -32,24 +32,29 @@ struct TailFT120 { uint8_t reserved1[7]; uint16_t column_id; - uint8_t frame_id; // counter, 0-255; incremented at each new scan + uint8_t frame_id; // counter, 0-255; incremented at each new scan uint8_t reserved2; uint8_t return_mode; - uint16_t frame_period; // 100, ms (sensor works @ 10Hz) + uint16_t frame_period; // 100, ms (sensor works @ 10Hz) SecondsSinceEpoch date_time; uint32_t timestamp; uint8_t factory_information; // fixed, 0x42 uint32_t udp_sequence; uint32_t crc_tail; - uint32_t signature[4]; // packet AES signature, pre-header to crc_tail; 0, if no key set in sensor + uint32_t + signature[4]; // packet AES signature, pre-header to crc_tail; 0, if no key set in sensor }; -struct PacketFT120 : public PacketBase<1, 120, 2, 160> // using degreeSubdivisions as the column count, to be supplied in AngleCorrectorCalibrationBasedSolidState +struct PacketFT120 +: public PacketBase<1, 120, 2, 160> // using degreeSubdivisions as the column count, to be supplied + // in AngleCorrectorCalibrationBasedSolidState { - using body_t = Body, PacketFT120::n_blocks>; // manual, 3.1.2.3 + using body_t = Body< + NoAzimuthBlock, PacketFT120::n_blocks>; // manual, 3.1.2.3 Header19B header; body_t body; - TailFT120 tail; // tail contains ColumnID value, used to identify the column of sensor readings inside the packet + TailFT120 tail; // tail contains ColumnID value, used to identify the column of sensor readings + // inside the packet /* Ignored optional fields */ // 3.1.3. Ethernet tail, 4 more bytes for frame check sequence @@ -60,18 +65,17 @@ struct PacketFT120 : public PacketBase<1, 120, 2, 160> // using degreeSubdivisi } // namespace hesai_packet -class PandarFT120 : public -HesaiSensor +class PandarFT120 : public HesaiSensor { private: - public: static constexpr float min_range = 0.05; static constexpr float max_range = 25.0; static constexpr int32_t col_N = 160; static constexpr int32_t row_N = 120; static constexpr size_t max_scan_buffer_points = col_N * row_N; - static constexpr FieldOfView fov_mdeg{{40'000, 140'000}, {-37'500, 37'500}}; + static constexpr FieldOfView fov_mdeg{ + {40'000, 140'000}, {-37'500, 37'500}}; static constexpr AnglePair peak_resolution_mdeg{ (fov_mdeg.azimuth.end - fov_mdeg.azimuth.start) / col_N, (fov_mdeg.elevation.end - fov_mdeg.elevation.start) / row_N, @@ -81,11 +85,11 @@ HesaiSensor uint32_t block_id, uint32_t channel_id, const packet_t & packet) override { // avoid warning "unused parameter" - (void) block_id; - (void) channel_id; - (void) packet; + (void)block_id; + (void)channel_id; + (void)packet; - return 0; // all measurements are took at the same time + return 0; // all measurements are took at the same time } ReturnType get_return_type( @@ -96,7 +100,7 @@ HesaiSensor // - return_mode is a copy of PandarFT120.tail.return_mode // - return_idx is a copy of PandarFT120.header.return_num - (void) return_units; + (void)return_units; switch (return_mode) { case hesai_packet::return_mode::SINGLE_FIRST: diff --git a/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp b/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp index f5c00dea0..c1994ea45 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp +++ b/src/nebula_hesai/nebula_hesai_decoders/src/hesai_driver.cpp @@ -118,7 +118,9 @@ std::shared_ptr HesaiDriver::initialize_decoder( using CalibT = typename SensorT::angle_corrector_t::correction_data_t; - using HesaiDecoderTp = typename std::conditional<(std::is_same::value), HesaiSolidStateDecoder, HesaiDecoder>::type; + using HesaiDecoderTp = typename std::conditional< + (std::is_same::value), HesaiSolidStateDecoder, + HesaiDecoder>::type; return std::make_shared( sensor_configuration, std::dynamic_pointer_cast(calibration_configuration), diff --git a/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp b/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp index 3ac0ab0ec..65c785b9d 100644 --- a/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp +++ b/src/nebula_hesai/nebula_hesai_hw_interfaces/include/nebula_hesai_hw_interfaces/hesai_cmd_response.hpp @@ -451,30 +451,32 @@ struct HesaiInventory_AT128 : public HesaiInventoryBase struct HesaiInventory_FT120 : public HesaiInventoryBase { - struct Internal // : public HesaiInventoryBase::Internal + struct Internal // : public HesaiInventoryBase::Internal { // byte 0-15: SN char sn[18]; // byte 19: model name, char --> 17-24 + empty bytes char product_name[32]; - char date_of_manufacture[16]; // yyyy-mm-dd + 6 empty bytes + char date_of_manufacture[16]; // yyyy-mm-dd + 6 empty bytes uint8_t mac[6]; char sw_ver[16]; - char unknown[16]; // contains string "unused" - char hw_ver[16]; // firmware version - char unknown2[16]; // contains string "3" + char unknown[16]; // contains string "unused" + char hw_ver[16]; // firmware version + char unknown2[16]; // contains string "3" char control_fw_ver[16]; char sensor_fw_ver[16]; - char unknown3[16]; // contains string "51e19f60" - char unknown4[16]; // contains string "a3db47f7" + char unknown3[16]; // contains string "51e19f60" + char unknown4[16]; // contains string "a3db47f7" char unknown5[4]; uint8_t product_model; // ex: 78; char: x; dec: 120! - char unknown6[11]; // contains string "x" in the middle + char unknown6[11]; // contains string "x" in the middle }; - explicit HesaiInventory_FT120(Internal value) : value(value) { + explicit HesaiInventory_FT120(Internal value) : value(value) + { std::memcpy(base.sn, value.sn, sizeof(base.sn)); - std::memcpy(base.date_of_manufacture, value.date_of_manufacture, sizeof(base.date_of_manufacture)); + std::memcpy( + base.date_of_manufacture, value.date_of_manufacture, sizeof(base.date_of_manufacture)); std::memcpy(base.mac, value.mac, sizeof(base.mac)); std::memcpy(base.sw_ver, value.sw_ver, sizeof(base.sw_ver)); std::memcpy(base.hw_ver, value.hw_ver, sizeof(base.hw_ver)); @@ -500,7 +502,6 @@ struct HesaiInventory_FT120 : public HesaiInventoryBase HesaiInventoryBase::Internal base; }; - /// @brief struct of PTC_COMMAND_GET_CONFIG_INFO struct HesaiConfigBase { @@ -622,18 +623,19 @@ struct HesaiConfig_OT128_AT128 : public HesaiConfigBase struct HesaiConfig_FT120 : public HesaiConfigBase { - struct Internal // structure does not begin like HesaiConfigBase::Internal + struct Internal // structure does not begin like HesaiConfigBase::Internal { uint8_t ipaddr[4]; uint8_t mask[4]; uint8_t gateway[4]; uint8_t dest_ipaddr[4]; big_uint16_buf_t dest_LiDAR_udp_port; - uint8_t unknown1[4]; // here there are some numbers; their meaning is unknown... Samples: 126 39 0 (maybe sync_angle) 200 (maybe fake rpm) + uint8_t unknown1[4]; // here there are some numbers; their meaning is unknown... Samples: 126 + // 39 0 (maybe sync_angle) 200 (maybe fake rpm) uint8_t unknown2[10]; uint8_t unknown3; uint8_t unknown4[8]; - big_uint16_buf_t dest_gps_udp_port; // byte 41 + big_uint16_buf_t dest_gps_udp_port; // byte 41 // here the last four bytes can be used to simulate some rotating sensors standard parameters big_uint16_buf_t spin_rate; @@ -641,8 +643,8 @@ struct HesaiConfig_FT120 : public HesaiConfigBase uint8_t unknown5; }; - explicit HesaiConfig_FT120(Internal value) : value(value) { - + explicit HesaiConfig_FT120(Internal value) : value(value) + { std::memcpy(base.ipaddr, value.ipaddr, sizeof(base.ipaddr)); std::memcpy(base.mask, value.mask, sizeof(base.mask)); std::memcpy(base.gateway, value.gateway, sizeof(base.gateway)); @@ -664,11 +666,11 @@ struct HesaiConfig_FT120 : public HesaiConfigBase private: Internal value; - // adding a HesaiConfigBase::Internal private structure to be used for the output of the get() method + // adding a HesaiConfigBase::Internal private structure to be used for the output of the get() + // method HesaiConfigBase::Internal base; }; - struct HesaiConfig_XT_40P_64_QT128 : public HesaiConfigBase { struct Internal : public HesaiConfigBase::Internal @@ -899,10 +901,9 @@ struct HesaiLidarStatusOT128 : public HesaiLidarStatusBase Internal value; }; - struct HesaiLidarStatusFT120 : public HesaiLidarStatus_AT128_QT128_FT120 { - explicit HesaiLidarStatusFT120(Internal value) : HesaiLidarStatus_AT128_QT128_FT120(value) { } + explicit HesaiLidarStatusFT120(Internal value) : HesaiLidarStatus_AT128_QT128_FT120(value) {} protected: [[nodiscard]] std::array get_temperature_names() const override diff --git a/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp b/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp index 22deeb37b..b186f2f9c 100644 --- a/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp +++ b/src/nebula_hesai/nebula_hesai_hw_interfaces/src/hesai_hw_interface.cpp @@ -640,9 +640,8 @@ HesaiLidarRangeAll HesaiHwInterface::get_lidar_range() { if ( sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128 || - sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64 - || sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120 - ) { + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDAR64 || + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { throw std::runtime_error("Not supported on this sensor"); } @@ -726,8 +725,9 @@ bool HesaiHwInterface::get_up_close_blockage_detection() Status HesaiHwInterface::check_and_set_lidar_range( const HesaiCalibrationConfigurationBase & calibration) { - if (sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128 - || sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { + if ( + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARAT128 || + sensor_configuration_->sensor_model == SensorModel::HESAI_PANDARFT120) { return Status::SENSOR_CONFIG_ERROR; } From 585e85c4d6506bb4d5412bcdede78841cf9bff05 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Wed, 4 Mar 2026 12:14:51 +0100 Subject: [PATCH 12/20] Add includes as requested by cpplint. --- .../include/nebula_hesai_decoders/decoders/hesai_sensor.hpp | 1 + .../include/nebula_hesai_decoders/decoders/pandar_ft120.hpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp index 8983374c1..530866644 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp @@ -26,6 +26,7 @@ #include #include #include +#include namespace nebula::drivers { diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp index 90339d1a0..b6f21cae9 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp @@ -20,6 +20,8 @@ #include +#include + namespace nebula::drivers { From 979f640658a3ccd7d0138ba110a64ea428781212 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:16:48 +0000 Subject: [PATCH 13/20] ci(pre-commit): autofix --- .../include/nebula_hesai_decoders/decoders/hesai_sensor.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp index 530866644..2f4d733e2 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_sensor.hpp @@ -24,9 +24,9 @@ #include #include +#include #include #include -#include namespace nebula::drivers { From 1656be26e5a218cf9fbc9716fdedd232bc6899e6 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Mon, 16 Mar 2026 12:49:54 +0100 Subject: [PATCH 14/20] Added safe read and size checking when reading sensor calibration file. --- .../include/nebula_hesai_common/hesai_common.hpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp index 21969704b..f1e1e2600 100644 --- a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp +++ b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp @@ -605,14 +605,18 @@ struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase nebula::Status load_from_bytes(const std::vector & buf) override { // get the matrix info from buffer - auto raw_ptr = buf.data(); - col_count = raw_ptr[6]; - row_count = raw_ptr[7]; - resolution = raw_ptr[8]; + 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]); azimuth_adjust.resize(count); From 7d6338b622985dd6c0c7733f23b091d85f252f4e Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Mon, 16 Mar 2026 14:39:36 +0100 Subject: [PATCH 15/20] Renamed return_num in packet header structure to first_block_return. --- .../decoders/hesai_decoder.hpp | 2 +- .../decoders/hesai_packet.hpp | 16 ++++++++-------- .../decoders/pandar_ft120.hpp | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp index e02410a89..4e33a4d22 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp @@ -488,7 +488,7 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State senso // If the blockage mask plugin is not present, we can return early if distance checks fail const bool filters_can_return_early = !blockage_mask_plugin_; - const unsigned int return_idx = packet_.header.return_num; + const unsigned int return_idx = packet_.header.first_block_return; const auto return_type = sensor_.get_return_type( static_cast(packet_.tail.return_mode), return_idx, diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp index 2c1c3caa8..99efe3bb2 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp @@ -99,15 +99,15 @@ struct Header19B // FT120 uint8_t reserved1[2]; // manual, 3.1.2.2 - uint16_t column_num; // 160 - uint16_t row_num; // 120 - uint8_t column_res; // 63, to be multiplied by standard coefficient of 0.01° - uint8_t row_res; // 63, to be multiplied by standard coefficient of 0.01° - uint8_t return_num; // 0, single return, 1 dual return & block 1 returns first type of dual mode; - // 2 dual return & block 1 returns second type of dual mode; - uint8_t dis_unit; // 4, mm + uint16_t column_num; // 160 + uint16_t row_num; // 120 + uint8_t column_res; // 63, to be multiplied by standard coefficient of 0.01° + uint8_t row_res; // 63, to be multiplied by standard coefficient of 0.01° + uint8_t first_block_return; // 0, single return, 1 dual return & block 1 returns first type of dual mode; + // 2 dual return & block 1 returns second type of dual mode; + uint8_t dis_unit; // 4, mm uint8_t reserved2; - uint16_t block_row_num; // 120 + uint16_t block_row_num; // 120 uint8_t reserved3[8]; }; diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp index b6f21cae9..c422c5de9 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp @@ -100,7 +100,7 @@ class PandarFT120 : public HesaiSensor Date: Mon, 16 Mar 2026 14:55:26 +0100 Subject: [PATCH 16/20] Changed AES signature type. --- .../include/nebula_hesai_decoders/decoders/pandar_ft120.hpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp index c422c5de9..7fc84563c 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp @@ -43,8 +43,7 @@ struct TailFT120 uint8_t factory_information; // fixed, 0x42 uint32_t udp_sequence; uint32_t crc_tail; - uint32_t - signature[4]; // packet AES signature, pre-header to crc_tail; 0, if no key set in sensor + uint8_t signature[16]; // packet AES signature, pre-header to crc_tail; 0, if no key set in sensor }; struct PacketFT120 From 0c9b5d986a6a23b7aceee0cbc4f1d6c013341515 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Mon, 16 Mar 2026 15:04:49 +0100 Subject: [PATCH 17/20] Renamed variables to match coding style and improve --- ...orrector_calibration_based_solid_state.hpp | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp index 7323b28f3..9813f90ef 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/angle_corrector_calibration_based_solid_state.hpp @@ -36,7 +36,7 @@ class AngleCorrectorCalibrationBasedSolidState : public AngleCorrector { private: - std::array, ColumnN> correctedAngleData; + std::array, ColumnN> corrected_angle_data; public: explicit AngleCorrectorCalibrationBasedSolidState( @@ -80,23 +80,23 @@ class AngleCorrectorCalibrationBasedSolidState ++calib_i; - auto C = CorrectedAngleData(); + auto pixel_angle_data = CorrectedAngleData(); - C.azimuth_rad = static_cast(azi); - C.elevation_rad = static_cast(ele); - C.sin_azimuth = static_cast(sin(azi)); - C.cos_azimuth = static_cast(cos(azi)); - C.sin_elevation = static_cast(sin(ele)); - C.cos_elevation = static_cast(cos(ele)); + pixel_angle_data.azimuth_rad = static_cast(azi); + pixel_angle_data.elevation_rad = static_cast(ele); + pixel_angle_data.sin_azimuth = static_cast(sin(azi)); + pixel_angle_data.cos_azimuth = static_cast(cos(azi)); + pixel_angle_data.sin_elevation = static_cast(sin(ele)); + pixel_angle_data.cos_elevation = static_cast(cos(ele)); - correctedAngleData[j][i] = C; + corrected_angle_data[j][i] = pixel_angle_data; } } } [[nodiscard]] CorrectedAngleData get_corrected_angle_data(uint32_t row_id, uint32_t col_id) const { - return correctedAngleData[col_id][row_id]; + return corrected_angle_data[col_id][row_id]; } // this base method is not used for solid state sensor, as all angles came from From b942dc9ef8bbb0d69046aecfbaa1af8236c68522 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:21:28 +0000 Subject: [PATCH 18/20] ci(pre-commit): autofix --- .../nebula_hesai_common/hesai_common.hpp | 4 ++-- .../decoders/hesai_packet.hpp | 17 +++++++++-------- .../decoders/pandar_ft120.hpp | 3 ++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp index f1e1e2600..ec900297d 100644 --- a/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp +++ b/src/nebula_hesai/nebula_hesai_common/include/nebula_hesai_common/hesai_common.hpp @@ -613,8 +613,8 @@ struct HesaiSolidStateCalibration : public HesaiCalibrationConfigurationBase 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; + if (buf.size() < (9 + 2 * count_bytes)) { + return Status::INVALID_CALIBRATION_FILE; } auto ref = &(buf[9]); diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp index 99efe3bb2..6d35783de 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp @@ -99,15 +99,16 @@ struct Header19B // FT120 uint8_t reserved1[2]; // manual, 3.1.2.2 - uint16_t column_num; // 160 - uint16_t row_num; // 120 - uint8_t column_res; // 63, to be multiplied by standard coefficient of 0.01° - uint8_t row_res; // 63, to be multiplied by standard coefficient of 0.01° - uint8_t first_block_return; // 0, single return, 1 dual return & block 1 returns first type of dual mode; - // 2 dual return & block 1 returns second type of dual mode; - uint8_t dis_unit; // 4, mm + uint16_t column_num; // 160 + uint16_t row_num; // 120 + uint8_t column_res; // 63, to be multiplied by standard coefficient of 0.01° + uint8_t row_res; // 63, to be multiplied by standard coefficient of 0.01° + uint8_t + first_block_return; // 0, single return, 1 dual return & block 1 returns first type of dual + // mode; 2 dual return & block 1 returns second type of dual mode; + uint8_t dis_unit; // 4, mm uint8_t reserved2; - uint16_t block_row_num; // 120 + uint16_t block_row_num; // 120 uint8_t reserved3[8]; }; diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp index 7fc84563c..478beb0d7 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/pandar_ft120.hpp @@ -43,7 +43,8 @@ struct TailFT120 uint8_t factory_information; // fixed, 0x42 uint32_t udp_sequence; uint32_t crc_tail; - uint8_t signature[16]; // packet AES signature, pre-header to crc_tail; 0, if no key set in sensor + uint8_t + signature[16]; // packet AES signature, pre-header to crc_tail; 0, if no key set in sensor }; struct PacketFT120 From 49c8f67f24b1103dab4f9a10323729fd6c143293 Mon Sep 17 00:00:00 2001 From: "m.giardino" Date: Mon, 16 Mar 2026 16:26:29 +0100 Subject: [PATCH 19/20] Bugfix: write points into output_frame_ when the first packet of a new scan is received. --- .../include/nebula_hesai_decoders/decoders/hesai_decoder.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp index 4e33a4d22..b2b51722a 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp @@ -572,7 +572,7 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State senso continue; } - bool in_current_scan = true; + bool in_current_scan = 0 != column_id; // write in output_frame as frames are being swapped after this method exit auto & frame = in_current_scan ? decode_frame_ : output_frame_; From 77a6e03a7a7b94524ee649b614cd1622a42b06f6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:27:12 +0000 Subject: [PATCH 20/20] ci(pre-commit): autofix --- .../include/nebula_hesai_decoders/decoders/hesai_decoder.hpp | 4 +++- .../include/nebula_hesai_decoders/decoders/hesai_packet.hpp | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp index b2b51722a..638bdea8e 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_decoder.hpp @@ -572,7 +572,9 @@ class HesaiSolidStateDecoder : public HesaiScanDecoder // for Solid State senso continue; } - bool in_current_scan = 0 != column_id; // write in output_frame as frames are being swapped after this method exit + bool in_current_scan = + 0 != + column_id; // write in output_frame as frames are being swapped after this method exit auto & frame = in_current_scan ? decode_frame_ : output_frame_; diff --git a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp index 6d35783de..7e3459e16 100644 --- a/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp +++ b/src/nebula_hesai/nebula_hesai_decoders/include/nebula_hesai_decoders/decoders/hesai_packet.hpp @@ -106,7 +106,7 @@ struct Header19B // FT120 uint8_t first_block_return; // 0, single return, 1 dual return & block 1 returns first type of dual // mode; 2 dual return & block 1 returns second type of dual mode; - uint8_t dis_unit; // 4, mm + uint8_t dis_unit; // 4, mm uint8_t reserved2; uint16_t block_row_num; // 120 uint8_t reserved3[8];