diff --git a/src/nebula_hesai/nebula_hesai_common/CMakeLists.txt b/src/nebula_hesai/nebula_hesai_common/CMakeLists.txt index 383896f21..a1b58ad09 100644 --- a/src/nebula_hesai/nebula_hesai_common/CMakeLists.txt +++ b/src/nebula_hesai/nebula_hesai_common/CMakeLists.txt @@ -13,6 +13,15 @@ target_include_directories( target_link_libraries(nebula_hesai_common INTERFACE nebula_core_common::nebula_core_common) +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + find_package(ament_cmake_gtest REQUIRED) + ament_lint_auto_find_test_dependencies() + + ament_add_gtest(test_hesai_common test/test_hesai_common.cpp) + target_link_libraries(test_hesai_common nebula_hesai_common) +endif() + install(TARGETS nebula_hesai_common EXPORT export_nebula_hesai_common) install(DIRECTORY include/${PROJECT_NAME}/ DESTINATION include/${PROJECT_NAME}) diff --git a/src/nebula_hesai/nebula_hesai_common/package.xml b/src/nebula_hesai/nebula_hesai_common/package.xml index fa4c581c3..3a35881d0 100644 --- a/src/nebula_hesai/nebula_hesai_common/package.xml +++ b/src/nebula_hesai/nebula_hesai_common/package.xml @@ -15,6 +15,9 @@ nebula_core_common + ament_cmake_gtest + ament_lint_auto + ament_cmake diff --git a/src/nebula_hesai/nebula_hesai_common/test/test_hesai_common.cpp b/src/nebula_hesai/nebula_hesai_common/test/test_hesai_common.cpp new file mode 100644 index 000000000..8fd2754ce --- /dev/null +++ b/src/nebula_hesai/nebula_hesai_common/test/test_hesai_common.cpp @@ -0,0 +1,341 @@ +// Copyright 2026 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. + +#include "nebula_hesai_common/hesai_common.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +using nebula::drivers::AdvancedFunctionalSafetyConfiguration; +using nebula::drivers::HesaiSensorConfiguration; +using nebula::drivers::PtpProfile; +using nebula::drivers::PtpSwitchType; +using nebula::drivers::PtpTransportType; +using nebula::drivers::ReturnMode; +using nebula::drivers::SensorModel; + +template +std::string stream_to_string(const T & value) +{ + std::ostringstream stream; + stream << value; + return stream.str(); +} + +void expect_contains_all( + const std::string & output, std::initializer_list expected_substrings) +{ + for (const auto expected_substring : expected_substrings) { + EXPECT_NE(output.find(expected_substring), std::string::npos) << output; + } +} + +void expect_hesai_return_mode_round_trip( + const SensorModel sensor_model, + std::initializer_list> expected_values) +{ + for (const auto & [string_value, int_value, expected_mode] : expected_values) { + EXPECT_EQ( + nebula::drivers::return_mode_from_string_hesai(std::string(string_value), sensor_model), + expected_mode); + EXPECT_EQ(nebula::drivers::return_mode_from_int_hesai(int_value, sensor_model), expected_mode); + EXPECT_EQ(nebula::drivers::int_from_return_mode_hesai(expected_mode, sensor_model), int_value); + } +} + +TEST(HesaiCommonTest, PtpProfilesParseCaseInsensitivelyAndStreamExpectedValues) +{ + constexpr std::array, 3> + expected_values{{ + {"1588V2", PtpProfile::IEEE_1588v2, "IEEE_1588v2"}, + {"802.1AS", PtpProfile::IEEE_802_1AS, "IEEE_802.1AS"}, + {"AUTOMOTIVE", PtpProfile::IEEE_802_1AS_AUTO, "IEEE_802.1AS Automotive"}, + }}; + + for (const auto & [string_value, expected_profile, streamed_value] : expected_values) { + EXPECT_EQ( + nebula::drivers::ptp_profile_from_string(std::string(string_value)), expected_profile); + EXPECT_EQ(stream_to_string(expected_profile), streamed_value); + } + + EXPECT_EQ(nebula::drivers::ptp_profile_from_string("unsupported"), PtpProfile::UNKNOWN_PROFILE); + EXPECT_EQ(stream_to_string(PtpProfile::UNKNOWN_PROFILE), "UNKNOWN"); +} + +TEST(HesaiCommonTest, PtpTransportTypesParseCaseInsensitivelyAndStreamExpectedValues) +{ + constexpr std::array, 2> + expected_values{{ + {"UDP", PtpTransportType::UDP_IP, "UDP/IP"}, + {"l2", PtpTransportType::L2, "L2"}, + }}; + + for (const auto & [string_value, expected_transport, streamed_value] : expected_values) { + EXPECT_EQ( + nebula::drivers::ptp_transport_type_from_string(std::string(string_value)), + expected_transport); + EXPECT_EQ(stream_to_string(expected_transport), streamed_value); + } + + EXPECT_EQ( + nebula::drivers::ptp_transport_type_from_string("unsupported"), + PtpTransportType::UNKNOWN_TRANSPORT); + EXPECT_EQ(stream_to_string(PtpTransportType::UNKNOWN_TRANSPORT), "UNKNOWN"); +} + +TEST(HesaiCommonTest, PtpSwitchTypesParseCaseInsensitivelyAndStreamExpectedValues) +{ + constexpr std::array, 2> + expected_values{{ + {"TSN", PtpSwitchType::TSN, "TSN"}, + {"non_tsn", PtpSwitchType::NON_TSN, "NON_TSN"}, + }}; + + for (const auto & [string_value, expected_switch, streamed_value] : expected_values) { + EXPECT_EQ( + nebula::drivers::ptp_switch_type_from_string(std::string(string_value)), expected_switch); + EXPECT_EQ(stream_to_string(expected_switch), streamed_value); + } + + EXPECT_EQ( + nebula::drivers::ptp_switch_type_from_string("unsupported"), PtpSwitchType::UNKNOWN_SWITCH); + EXPECT_EQ(stream_to_string(PtpSwitchType::UNKNOWN_SWITCH), "UNKNOWN"); +} + +TEST(HesaiCommonTest, ReturnModeConversionsRoundTripForXtFamily) +{ + constexpr SensorModel sensor_model = SensorModel::HESAI_PANDARXT32; + + expect_hesai_return_mode_round_trip( + sensor_model, {{"Last", 0, ReturnMode::LAST}, + {"Strongest", 1, ReturnMode::STRONGEST}, + {"Dual", 2, ReturnMode::DUAL_LAST_STRONGEST}, + {"First", 3, ReturnMode::FIRST}, + {"LastFirst", 4, ReturnMode::DUAL_LAST_FIRST}, + {"FirstStrongest", 5, ReturnMode::DUAL_FIRST_STRONGEST}}); + + EXPECT_EQ( + nebula::drivers::return_mode_from_string_hesai("LastStrongest", sensor_model), + ReturnMode::DUAL_LAST_STRONGEST); + EXPECT_EQ(nebula::drivers::int_from_return_mode_hesai(ReturnMode::DUAL, sensor_model), 2); +} + +TEST(HesaiCommonTest, ReturnModeConversionsRoundTripForQt64Family) +{ + constexpr SensorModel sensor_model = SensorModel::HESAI_PANDARQT64; + + expect_hesai_return_mode_round_trip( + sensor_model, {{"Last", 0, ReturnMode::LAST}, + {"Dual", 2, ReturnMode::DUAL_LAST_FIRST}, + {"First", 3, ReturnMode::FIRST}}); + + EXPECT_EQ( + nebula::drivers::return_mode_from_string_hesai("LastFirst", sensor_model), + ReturnMode::DUAL_LAST_FIRST); + EXPECT_EQ(nebula::drivers::int_from_return_mode_hesai(ReturnMode::DUAL, sensor_model), 2); +} + +TEST(HesaiCommonTest, ReturnModeConversionsRoundTripForLegacyDualFamily) +{ + constexpr SensorModel sensor_model = SensorModel::HESAI_PANDARAT128; + + expect_hesai_return_mode_round_trip( + sensor_model, {{"Last", 0, ReturnMode::LAST}, + {"Strongest", 1, ReturnMode::STRONGEST}, + {"Dual", 2, ReturnMode::DUAL_LAST_STRONGEST}}); + + EXPECT_EQ( + nebula::drivers::return_mode_from_string_hesai("LastStrongest", sensor_model), + ReturnMode::DUAL_LAST_STRONGEST); + EXPECT_EQ(nebula::drivers::int_from_return_mode_hesai(ReturnMode::DUAL, sensor_model), 2); +} + +TEST(HesaiCommonTest, ReturnModeConversionsHandleUnexpectedValues) +{ + constexpr SensorModel xt_sensor_model = SensorModel::HESAI_PANDARXT16; + constexpr SensorModel at_sensor_model = SensorModel::HESAI_PANDAR64; + + EXPECT_EQ( + nebula::drivers::return_mode_from_string_hesai("last", xt_sensor_model), ReturnMode::UNKNOWN); + EXPECT_EQ(nebula::drivers::return_mode_from_int_hesai(99, xt_sensor_model), ReturnMode::UNKNOWN); + EXPECT_EQ( + nebula::drivers::return_mode_from_string_hesai("Strongest", SensorModel::HESAI_PANDARQT64), + ReturnMode::UNKNOWN); + EXPECT_EQ( + nebula::drivers::return_mode_from_int_hesai(1, SensorModel::HESAI_PANDARQT64), + ReturnMode::UNKNOWN); + EXPECT_EQ(nebula::drivers::int_from_return_mode_hesai(ReturnMode::UNKNOWN, xt_sensor_model), -1); + EXPECT_EQ(nebula::drivers::int_from_return_mode_hesai(ReturnMode::FIRST, at_sensor_model), -1); +} + +TEST(HesaiCommonTest, ReturnModeConversionsRejectUnsupportedSensorModels) +{ + constexpr SensorModel unsupported_model = SensorModel::VELODYNE_VLP16; + + EXPECT_THROW( + nebula::drivers::return_mode_from_string_hesai("Last", unsupported_model), std::runtime_error); + EXPECT_THROW( + nebula::drivers::return_mode_from_int_hesai(0, unsupported_model), std::runtime_error); + EXPECT_THROW( + nebula::drivers::int_from_return_mode_hesai(ReturnMode::LAST, unsupported_model), + std::runtime_error); +} + +TEST(HesaiCommonTest, SupportPredicatesReflectVendorCapabilities) +{ + EXPECT_FALSE(nebula::drivers::supports_lidar_monitor(SensorModel::HESAI_PANDARAT128)); + EXPECT_FALSE(nebula::drivers::supports_lidar_monitor(SensorModel::HESAI_PANDAR40P)); + EXPECT_TRUE(nebula::drivers::supports_lidar_monitor(SensorModel::HESAI_PANDARXT32)); + + EXPECT_TRUE(nebula::drivers::supports_functional_safety(SensorModel::HESAI_PANDAR128_E3X)); + EXPECT_TRUE(nebula::drivers::supports_functional_safety(SensorModel::HESAI_PANDAR128_E4X)); + EXPECT_TRUE(nebula::drivers::supports_functional_safety(SensorModel::HESAI_PANDARQT128)); + EXPECT_FALSE(nebula::drivers::supports_functional_safety(SensorModel::HESAI_PANDARXT32)); + + EXPECT_TRUE(nebula::drivers::supports_packet_loss_detection(SensorModel::HESAI_PANDAR40M)); + EXPECT_TRUE(nebula::drivers::supports_packet_loss_detection(SensorModel::HESAI_PANDAR128_E4X)); + EXPECT_FALSE(nebula::drivers::supports_packet_loss_detection(SensorModel::HESAI_PANDARXT16)); + + EXPECT_TRUE(nebula::drivers::supports_blockage_mask(SensorModel::HESAI_PANDAR128_E4X)); + EXPECT_FALSE(nebula::drivers::supports_blockage_mask(SensorModel::HESAI_PANDAR128_E3X)); +} + +TEST(HesaiCommonTest, AdvancedFunctionalSafetyConfigurationStreamingReflectsConfiguredValues) +{ + const AdvancedFunctionalSafetyConfiguration advanced{"/tmp/error-definitions.json", {0x1a, 0x2b}}; + const AdvancedFunctionalSafetyConfiguration no_exemptions{"/tmp/error-definitions.json", {}}; + + expect_contains_all( + stream_to_string(advanced), {"advanced", "/tmp/error-definitions.json", "0x1a, 0x2b"}); + expect_contains_all( + stream_to_string(no_exemptions), {"advanced", "/tmp/error-definitions.json", "none"}); +} + +TEST(HesaiCommonTest, DefaultInitializedSensorConfigurationStreamsWithoutCrashing) +{ + const HesaiSensorConfiguration configuration{}; + EXPECT_NO_THROW({ + const auto output = stream_to_string(configuration); + EXPECT_FALSE(output.empty()); + }); +} + +TEST(HesaiCommonTest, HesaiSensorConfigurationStreamingReflectsConfiguredValues) +{ + HesaiSensorConfiguration configuration{}; + configuration.sensor_model = SensorModel::HESAI_PANDAR128_E4X; + configuration.frame_id = "hesai_frame"; + configuration.host_ip = "192.168.1.10"; + configuration.sensor_ip = "192.168.1.201"; + configuration.data_port = 2368; + configuration.return_mode = ReturnMode::DUAL_LAST_STRONGEST; + configuration.packet_mtu_size = 1200; + configuration.min_range = 1.0; + configuration.max_range = 200.0; + configuration.use_sensor_time = true; + configuration.multicast_ip = "239.1.2.3"; + configuration.gnss_port = 10110; + configuration.udp_socket_receive_buffer_size_bytes = 4096; + configuration.sync_angle = 123; + configuration.cut_angle = 45.5; + configuration.dual_return_distance_threshold = 0.75; + configuration.calibration_path = "/tmp/hesai.csv"; + configuration.calibration_download_enabled = true; + configuration.rotation_speed = 1200; + configuration.cloud_min_angle = 100; + configuration.cloud_max_angle = 200; + configuration.ptp_profile = PtpProfile::IEEE_802_1AS_AUTO; + configuration.ptp_domain = 7; + configuration.ptp_transport_type = PtpTransportType::UDP_IP; + configuration.ptp_switch_type = PtpSwitchType::TSN; + configuration.ptp_lock_threshold = 4; + configuration.downsample_mask_path = "/tmp/downsample.png"; + configuration.hires_mode = true; + configuration.blockage_mask_horizontal_bin_size_mdeg = 250; + configuration.sync_diagnostics_topic = "/sync_diag/graph_updates"; + configuration.functional_safety = + AdvancedFunctionalSafetyConfiguration{"/tmp/error-definitions.json", {0x1a, 0x2b}}; + + const std::string output = stream_to_string(configuration); + + expect_contains_all( + output, {"Hesai Sensor Configuration:", + "Sensor Model: Pandar128_E4X_OT", + "Frame ID: hesai_frame", + "Host IP: 192.168.1.10", + "Sensor IP: 192.168.1.201", + "Data Port: 2368", + "Return Mode: LastStrongest", + "MTU: 1200", + "Use Sensor Time: 1", + "Multicast: enabled, group: 239.1.2.3", + "GNSS Port: 10110", + "UDP Socket Receive Buffer Size: 4096 B", + "Rotation Speed: 1200", + "Sync Angle: 123", + "Cut Angle: 45.5", + "FoV Start: 100", + "FoV End: 200", + "Dual Return Distance Threshold: 0.75", + "Calibration Path: /tmp/hesai.csv", + "Calibration Download: enabled", + "PTP Profile: IEEE_802.1AS Automotive", + "PTP Domain: 7", + "PTP Transport Type: UDP/IP", + "PTP Switch Type: TSN", + "PTP Lock Threshold: 4", + "High Resolution Mode: enabled", + "Downsample Filter: enabled, path: /tmp/downsample.png", + "Blockage Mask Output: enabled, horizontal bin size: 250 mdeg", + "Synchronization Diagnostics: enabled, topic: /sync_diag/graph_updates", + "Functional Safety: advanced", + "/tmp/error-definitions.json", + "0x1a, 0x2b"}); +} + +TEST(HesaiCommonTest, HesaiSensorConfigurationStreamingHandlesDisabledOptionals) +{ + HesaiSensorConfiguration configuration{}; + configuration.sensor_model = SensorModel::HESAI_PANDARXT32; + + const std::string output = stream_to_string(configuration); + + expect_contains_all( + output, {"Multicast: disabled", "Calibration Download: disabled", + "High Resolution Mode: disabled", "Downsample Filter: disabled", + "Blockage Mask Output: disabled", "Synchronization Diagnostics: disabled"}); + EXPECT_EQ(output.find("Functional Safety:"), std::string::npos) << output; +} + +TEST(HesaiCommonTest, HesaiSensorConfigurationStreamingFallsBackToBasicFunctionalSafety) +{ + HesaiSensorConfiguration configuration{}; + configuration.sensor_model = SensorModel::HESAI_PANDARQT128; + + const std::string output = stream_to_string(configuration); + + EXPECT_NE(output.find("Functional Safety: basic"), std::string::npos) << output; +} + +} // namespace