cu_hesai/
lib.rs

1pub mod parser;
2
3use crate::parser::{RefTime, generate_default_elevation_calibration};
4use chrono::Utc;
5use cu_sensor_payloads::{PointCloud, PointCloudSoa};
6use cu29::prelude::*;
7use socket2::{Domain, Protocol, SockAddr, Socket, Type};
8use std::io::ErrorKind;
9use std::io::Read;
10use std::net::SocketAddr;
11use uom::si::f32::{Angle, Length};
12
13/// By default, Hesai broadcasts on this address.
14const DEFAULT_ADDR: &str = "0.0.0.0:2368";
15
16/// Convert spherical coordinates to cartesian coordinates.
17/// With the physics-style spherical coordinate convention / right-handed coordinate system.
18fn spherical_to_cartesian(
19    azimuth: Angle,
20    elevation: Angle,
21    distance: Length,
22) -> (Length, Length, Length) {
23    let x = distance * elevation.cos() * azimuth.cos();
24    let y = distance * elevation.cos() * azimuth.sin();
25    let z = distance * elevation.sin();
26    (x, y, z)
27}
28
29pub struct Xt32 {
30    socket: Socket,
31    reftime: RefTime,
32    channel_elevations: [Angle; 32],
33}
34
35impl Xt32 {
36    /// This give the matching timestamps between the UTC time to the Robot time.
37    fn sync(&mut self, robot_clock: &RobotClock) {
38        self.reftime = (Utc::now(), robot_clock.now());
39    }
40}
41
42impl Freezable for Xt32 {}
43
44const MAX_POINTS: usize = 32 * 10;
45
46pub type LidarCuMsgPayload = PointCloudSoa<MAX_POINTS>;
47
48// In each round of firing, the firing sequence is from Channel 1 to Channel 32.
49// Assuming that the start time of Block 6 is t6, the laser firing time of Channel i is
50// t6 + [1.512µs * (i-1) + 0.28µs ], i∈{1, 2, ..., 32}.
51// in copper resolution as 1ns
52// t6 + 1512ns * (i-1) + 280ns
53fn channel_time(t6: CuTime, i: u64) -> CuTime {
54    if i == 0 {
55        CuDuration(t6.0 - 1512 + 280) // this is an underflow, so we just subtract the value
56    } else {
57        CuDuration(t6.0 + 1512 * (i - 1) + 280)
58    }
59}
60
61impl CuSrcTask for Xt32 {
62    type Resources<'r> = ();
63    type Output<'m> = output_msg!(LidarCuMsgPayload);
64
65    fn new(config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
66    where
67        Self: Sized,
68    {
69        let addr: SocketAddr = if let Some(cfg) = config {
70            let addr_str = cfg.get("socket_addr").unwrap_or(DEFAULT_ADDR.to_string());
71            addr_str.as_str().parse().unwrap()
72        } else {
73            DEFAULT_ADDR.parse().unwrap()
74        };
75
76        let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).unwrap();
77        socket.bind(&SockAddr::from(addr)).unwrap();
78        socket.set_nonblocking(true).unwrap();
79
80        // just a temporary value, it will be redone at start.
81        let rt: RefTime = (Utc::now(), RobotClock::new().now());
82        Ok(Xt32 {
83            socket,
84            reftime: rt,
85            channel_elevations: generate_default_elevation_calibration(), // TODO: make the config able to override that
86        })
87    }
88    fn start(&mut self, robot_clock: &RobotClock) -> CuResult<()> {
89        self.sync(robot_clock);
90        Ok(())
91    }
92    fn process(&mut self, _clock: &RobotClock, new_msg: &mut Self::Output<'_>) -> CuResult<()> {
93        let payload = new_msg.payload_mut().insert(LidarCuMsgPayload::default());
94        let mut buf = [0u8; 1500];
95        match self.socket.read(&mut buf) {
96            Ok(size) => {
97                let lidar_packet = parser::parse_packet(&buf[..size])
98                    .map_err(|e| CuError::new_with_cause("Failed to parse Hesai UDP packet", e))?;
99                // this is the reference point for the block timings
100                let t6 = lidar_packet
101                    .block_ts(&self.reftime)
102                    .map_err(|e| CuError::new_with_cause("Failed to get block timings", e))?[5]; // 0 == channel 1, 5 == channel 6
103
104                let mut min_tov = CuTime::MAX;
105                let mut max_tov = CuTime::MIN;
106
107                // let is_dual = lidar_packet.header.is_dual_return(); TODO: add dual return support
108                for block in lidar_packet.blocks.iter() {
109                    let azimuth = block.azimuth();
110                    block.channels.iter().enumerate().for_each(|(i, c)| {
111                        let elevation = self.channel_elevations[i];
112                        let d = c.distance();
113                        let r = c.reflectivity();
114
115                        let (x, y, z) = spherical_to_cartesian(azimuth, elevation, d);
116                        let t = channel_time(t6, i as u64);
117                        // TODO: we can precompute that from the packet itself in one shot.
118                        if t < min_tov {
119                            min_tov = t;
120                        } else if t > max_tov {
121                            max_tov = t;
122                        }
123                        payload.push(PointCloud::new_uom(t, x, y, z, r, None));
124                    });
125                }
126
127                let tov_range = CuTimeRange {
128                    start: min_tov,
129                    end: max_tov,
130                };
131                new_msg.tov = Tov::Range(tov_range); // take the oldest timestamp
132            }
133            Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
134                // Handle no data available (non-blocking behavior)
135                new_msg.clear_payload();
136                return Ok(());
137            }
138            Err(e) => return Err(CuError::new_with_cause("IO Error on UDP socket", e)), // Handle other errors
139        }
140        Ok(())
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::parser::Packet;
148    use chrono::DateTime;
149    use cu_udp_inject::PcapStreamer;
150    use cu29::cutask::CuMsg;
151
152    #[test]
153    fn test_xt32() {
154        let clock = RobotClock::new();
155        let mut streamer = PcapStreamer::new("tests/hesai-xt32-small.pcap", "127.0.0.1:2368");
156        let config = ComponentConfig::new();
157
158        let mut xt32 = Xt32::new(Some(&config), ()).unwrap();
159
160        let new_payload = LidarCuMsgPayload::default();
161        let mut new_msg = CuMsg::<LidarCuMsgPayload>::new(Some(new_payload));
162
163        // Picking a timestamp from the beginning of the pcap file to align the robot clock with the capture + 1s buffer in the past because ref times are negative.
164        let datetime = DateTime::parse_from_rfc3339("2024-09-17T15:47:11.684855Z")
165            .unwrap()
166            .with_timezone(&Utc);
167
168        xt32.reftime = (datetime, clock.now());
169
170        // 1076 is the expected payload size for Hesai XT32
171        const PACKET_SIZE: usize = size_of::<Packet>();
172        while streamer
173            .send_next::<PACKET_SIZE>()
174            .expect("Failed to send next packet")
175        {
176            let err = xt32.process(&clock, &mut new_msg);
177            if let Err(e) = err {
178                println!("Error: {e:?}");
179                continue;
180            }
181            if let Some(payload) = new_msg.payload() {
182                println!("Lidar Payload: {payload:?}");
183            }
184            break;
185        }
186    }
187}