1use super::{ValidationError, ValidationResult};
6use crate::sensor::v1::{
7 FieldOfView, GimbalLimits, GimbalState, SensorMountType, SensorOrientation, SensorSpec,
8 SensorStateUpdate, SensorStatus,
9};
10
11pub fn validate_sensor_orientation(orientation: &SensorOrientation) -> ValidationResult<()> {
18 if orientation.bearing_offset_deg < 0.0 || orientation.bearing_offset_deg >= 360.0 {
20 return Err(ValidationError::InvalidValue(format!(
21 "bearing_offset_deg {} must be in range [0, 360)",
22 orientation.bearing_offset_deg
23 )));
24 }
25
26 if orientation.elevation_offset_deg < -90.0 || orientation.elevation_offset_deg > 90.0 {
28 return Err(ValidationError::InvalidValue(format!(
29 "elevation_offset_deg {} must be in range [-90, 90]",
30 orientation.elevation_offset_deg
31 )));
32 }
33
34 if orientation.roll_offset_deg < -180.0 || orientation.roll_offset_deg > 180.0 {
36 return Err(ValidationError::InvalidValue(format!(
37 "roll_offset_deg {} must be in range [-180, 180]",
38 orientation.roll_offset_deg
39 )));
40 }
41
42 Ok(())
43}
44
45pub fn validate_field_of_view(fov: &FieldOfView) -> ValidationResult<()> {
52 if fov.horizontal_deg <= 0.0 || fov.horizontal_deg >= 360.0 {
54 return Err(ValidationError::InvalidValue(format!(
55 "horizontal_deg {} must be in range (0, 360)",
56 fov.horizontal_deg
57 )));
58 }
59
60 if fov.vertical_deg <= 0.0 || fov.vertical_deg >= 180.0 {
62 return Err(ValidationError::InvalidValue(format!(
63 "vertical_deg {} must be in range (0, 180)",
64 fov.vertical_deg
65 )));
66 }
67
68 if fov.max_range_m < 0.0 {
70 return Err(ValidationError::InvalidValue(
71 "max_range_m must be non-negative".to_string(),
72 ));
73 }
74
75 Ok(())
76}
77
78pub fn validate_gimbal_limits(limits: &GimbalLimits) -> ValidationResult<()> {
86 if limits.pan_min_deg > limits.pan_max_deg {
87 return Err(ValidationError::ConstraintViolation(
88 "pan_min_deg must be <= pan_max_deg".to_string(),
89 ));
90 }
91
92 if limits.tilt_min_deg > limits.tilt_max_deg {
93 return Err(ValidationError::ConstraintViolation(
94 "tilt_min_deg must be <= tilt_max_deg".to_string(),
95 ));
96 }
97
98 if limits.roll_min_deg > limits.roll_max_deg {
99 return Err(ValidationError::ConstraintViolation(
100 "roll_min_deg must be <= roll_max_deg".to_string(),
101 ));
102 }
103
104 if limits.zoom_min <= 0.0 {
105 return Err(ValidationError::InvalidValue(
106 "zoom_min must be positive".to_string(),
107 ));
108 }
109
110 if limits.zoom_max < limits.zoom_min {
111 return Err(ValidationError::ConstraintViolation(
112 "zoom_max must be >= zoom_min".to_string(),
113 ));
114 }
115
116 Ok(())
117}
118
119pub fn validate_gimbal_state(
126 state: &GimbalState,
127 limits: Option<&GimbalLimits>,
128) -> ValidationResult<()> {
129 if state.zoom <= 0.0 {
131 return Err(ValidationError::InvalidValue(
132 "zoom must be positive".to_string(),
133 ));
134 }
135
136 if let Some(limits) = limits {
138 if state.pan_deg < limits.pan_min_deg || state.pan_deg > limits.pan_max_deg {
139 return Err(ValidationError::ConstraintViolation(format!(
140 "pan_deg {} must be within limits [{}, {}]",
141 state.pan_deg, limits.pan_min_deg, limits.pan_max_deg
142 )));
143 }
144
145 if state.tilt_deg < limits.tilt_min_deg || state.tilt_deg > limits.tilt_max_deg {
146 return Err(ValidationError::ConstraintViolation(format!(
147 "tilt_deg {} must be within limits [{}, {}]",
148 state.tilt_deg, limits.tilt_min_deg, limits.tilt_max_deg
149 )));
150 }
151
152 if state.zoom < limits.zoom_min || state.zoom > limits.zoom_max {
153 return Err(ValidationError::ConstraintViolation(format!(
154 "zoom {} must be within limits [{}, {}]",
155 state.zoom, limits.zoom_min, limits.zoom_max
156 )));
157 }
158 }
159
160 Ok(())
161}
162
163pub fn validate_sensor_spec(spec: &SensorSpec) -> ValidationResult<()> {
174 if spec.sensor_id.is_empty() {
176 return Err(ValidationError::MissingField("sensor_id".to_string()));
177 }
178
179 if spec.name.is_empty() {
180 return Err(ValidationError::MissingField("name".to_string()));
181 }
182
183 if spec.mount_type == SensorMountType::Unspecified as i32 {
185 return Err(ValidationError::InvalidValue(
186 "mount_type must be specified".to_string(),
187 ));
188 }
189
190 if let Some(ref orientation) = spec.base_orientation {
192 validate_sensor_orientation(orientation)?;
193 }
194
195 let fov = spec
197 .field_of_view
198 .as_ref()
199 .ok_or_else(|| ValidationError::MissingField("field_of_view".to_string()))?;
200 validate_field_of_view(fov)?;
201
202 let is_fixed = spec.mount_type == SensorMountType::Fixed as i32;
204
205 if !is_fixed {
206 if spec.gimbal_limits.is_none() {
208 return Err(ValidationError::MissingField(
209 "gimbal_limits (required for non-fixed mount types)".to_string(),
210 ));
211 }
212
213 if let Some(ref limits) = spec.gimbal_limits {
215 validate_gimbal_limits(limits)?;
216 }
217
218 if let Some(ref state) = spec.current_state {
220 validate_gimbal_state(state, spec.gimbal_limits.as_ref())?;
221 }
222 }
223
224 if spec.resolution_width > 0 && spec.resolution_height == 0 {
226 return Err(ValidationError::InvalidValue(
227 "resolution_height must be positive when resolution_width is set".to_string(),
228 ));
229 }
230
231 if spec.resolution_height > 0 && spec.resolution_width == 0 {
232 return Err(ValidationError::InvalidValue(
233 "resolution_width must be positive when resolution_height is set".to_string(),
234 ));
235 }
236
237 if spec.frame_rate_fps < 0.0 {
239 return Err(ValidationError::InvalidValue(
240 "frame_rate_fps must be non-negative".to_string(),
241 ));
242 }
243
244 Ok(())
245}
246
247pub fn validate_sensor_state_update(update: &SensorStateUpdate) -> ValidationResult<()> {
255 if update.platform_id.is_empty() {
256 return Err(ValidationError::MissingField("platform_id".to_string()));
257 }
258
259 let sensor = update
261 .sensor
262 .as_ref()
263 .ok_or_else(|| ValidationError::MissingField("sensor".to_string()))?;
264 validate_sensor_spec(sensor)?;
265
266 if update.status == SensorStatus::Unspecified as i32 {
268 return Err(ValidationError::InvalidValue(
269 "status must be specified".to_string(),
270 ));
271 }
272
273 if update.timestamp.is_none() {
275 return Err(ValidationError::MissingField("timestamp".to_string()));
276 }
277
278 Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::common::v1::Timestamp;
285 use crate::sensor::v1::SensorModality;
286
287 fn valid_fixed_sensor() -> SensorSpec {
288 SensorSpec {
289 sensor_id: "eo-main".to_string(),
290 name: "Main EO Camera".to_string(),
291 mount_type: SensorMountType::Fixed as i32,
292 base_orientation: Some(SensorOrientation {
293 bearing_offset_deg: 0.0, elevation_offset_deg: 0.0, roll_offset_deg: 0.0,
296 }),
297 field_of_view: Some(FieldOfView {
298 horizontal_deg: 62.0,
299 vertical_deg: 48.0,
300 diagonal_deg: 0.0,
301 max_range_m: 500.0,
302 }),
303 modality: SensorModality::Eo as i32,
304 resolution_width: 1920,
305 resolution_height: 1080,
306 frame_rate_fps: 30.0,
307 gimbal_limits: None, current_state: None,
309 updated_at: None,
310 }
311 }
312
313 fn valid_ptz_sensor() -> SensorSpec {
314 SensorSpec {
315 sensor_id: "ptz-tower".to_string(),
316 name: "Tower PTZ Camera".to_string(),
317 mount_type: SensorMountType::Ptz as i32,
318 base_orientation: Some(SensorOrientation {
319 bearing_offset_deg: 0.0,
320 elevation_offset_deg: 0.0,
321 roll_offset_deg: 0.0,
322 }),
323 field_of_view: Some(FieldOfView {
324 horizontal_deg: 45.0,
325 vertical_deg: 35.0,
326 diagonal_deg: 0.0,
327 max_range_m: 1000.0,
328 }),
329 modality: SensorModality::Eo as i32,
330 resolution_width: 3840,
331 resolution_height: 2160,
332 frame_rate_fps: 30.0,
333 gimbal_limits: Some(GimbalLimits {
334 pan_min_deg: -180.0,
335 pan_max_deg: 180.0,
336 tilt_min_deg: -30.0,
337 tilt_max_deg: 90.0,
338 roll_min_deg: 0.0,
339 roll_max_deg: 0.0,
340 zoom_min: 1.0,
341 zoom_max: 30.0,
342 pan_rate_max: 45.0,
343 tilt_rate_max: 30.0,
344 }),
345 current_state: Some(GimbalState {
346 pan_deg: 45.0,
347 tilt_deg: 15.0,
348 roll_deg: 0.0,
349 zoom: 2.0,
350 tracking: false,
351 tracked_target_id: String::new(),
352 }),
353 updated_at: None,
354 }
355 }
356
357 #[test]
358 fn test_valid_fixed_sensor() {
359 let sensor = valid_fixed_sensor();
360 assert!(validate_sensor_spec(&sensor).is_ok());
361 }
362
363 #[test]
364 fn test_valid_ptz_sensor() {
365 let sensor = valid_ptz_sensor();
366 assert!(validate_sensor_spec(&sensor).is_ok());
367 }
368
369 #[test]
370 fn test_missing_sensor_id() {
371 let mut sensor = valid_fixed_sensor();
372 sensor.sensor_id = String::new();
373 let err = validate_sensor_spec(&sensor).unwrap_err();
374 assert!(matches!(err, ValidationError::MissingField(f) if f == "sensor_id"));
375 }
376
377 #[test]
378 fn test_missing_name() {
379 let mut sensor = valid_fixed_sensor();
380 sensor.name = String::new();
381 let err = validate_sensor_spec(&sensor).unwrap_err();
382 assert!(matches!(err, ValidationError::MissingField(f) if f == "name"));
383 }
384
385 #[test]
386 fn test_unspecified_mount_type() {
387 let mut sensor = valid_fixed_sensor();
388 sensor.mount_type = SensorMountType::Unspecified as i32;
389 let err = validate_sensor_spec(&sensor).unwrap_err();
390 assert!(matches!(err, ValidationError::InvalidValue(_)));
391 }
392
393 #[test]
394 fn test_missing_fov() {
395 let mut sensor = valid_fixed_sensor();
396 sensor.field_of_view = None;
397 let err = validate_sensor_spec(&sensor).unwrap_err();
398 assert!(matches!(err, ValidationError::MissingField(f) if f == "field_of_view"));
399 }
400
401 #[test]
402 fn test_ptz_without_gimbal_limits() {
403 let mut sensor = valid_ptz_sensor();
404 sensor.gimbal_limits = None;
405 let err = validate_sensor_spec(&sensor).unwrap_err();
406 assert!(matches!(err, ValidationError::MissingField(_)));
407 }
408
409 #[test]
410 fn test_invalid_bearing() {
411 let mut sensor = valid_fixed_sensor();
412 sensor.base_orientation = Some(SensorOrientation {
413 bearing_offset_deg: 400.0, elevation_offset_deg: 0.0,
415 roll_offset_deg: 0.0,
416 });
417 let err = validate_sensor_spec(&sensor).unwrap_err();
418 assert!(matches!(err, ValidationError::InvalidValue(_)));
419 }
420
421 #[test]
422 fn test_invalid_elevation() {
423 let mut sensor = valid_fixed_sensor();
424 sensor.base_orientation = Some(SensorOrientation {
425 bearing_offset_deg: 0.0,
426 elevation_offset_deg: 100.0, roll_offset_deg: 0.0,
428 });
429 let err = validate_sensor_spec(&sensor).unwrap_err();
430 assert!(matches!(err, ValidationError::InvalidValue(_)));
431 }
432
433 #[test]
434 fn test_invalid_horizontal_fov() {
435 let mut sensor = valid_fixed_sensor();
436 sensor.field_of_view = Some(FieldOfView {
437 horizontal_deg: 0.0, vertical_deg: 48.0,
439 diagonal_deg: 0.0,
440 max_range_m: 500.0,
441 });
442 let err = validate_sensor_spec(&sensor).unwrap_err();
443 assert!(matches!(err, ValidationError::InvalidValue(_)));
444 }
445
446 #[test]
447 fn test_invalid_gimbal_pan_range() {
448 let mut sensor = valid_ptz_sensor();
449 sensor.gimbal_limits = Some(GimbalLimits {
450 pan_min_deg: 100.0,
451 pan_max_deg: -100.0, tilt_min_deg: -30.0,
453 tilt_max_deg: 90.0,
454 roll_min_deg: 0.0,
455 roll_max_deg: 0.0,
456 zoom_min: 1.0,
457 zoom_max: 30.0,
458 pan_rate_max: 45.0,
459 tilt_rate_max: 30.0,
460 });
461 let err = validate_sensor_spec(&sensor).unwrap_err();
462 assert!(matches!(err, ValidationError::ConstraintViolation(_)));
463 }
464
465 #[test]
466 fn test_gimbal_state_outside_limits() {
467 let mut sensor = valid_ptz_sensor();
468 sensor.current_state = Some(GimbalState {
469 pan_deg: 200.0, tilt_deg: 15.0,
471 roll_deg: 0.0,
472 zoom: 2.0,
473 tracking: false,
474 tracked_target_id: String::new(),
475 });
476 let err = validate_sensor_spec(&sensor).unwrap_err();
477 assert!(matches!(err, ValidationError::ConstraintViolation(_)));
478 }
479
480 #[test]
481 fn test_valid_sensor_state_update() {
482 let update = SensorStateUpdate {
483 platform_id: "UGV-Alpha-1".to_string(),
484 sensor: Some(valid_fixed_sensor()),
485 status: SensorStatus::Operational as i32,
486 timestamp: Some(Timestamp {
487 seconds: 1702000000,
488 nanos: 0,
489 }),
490 };
491 assert!(validate_sensor_state_update(&update).is_ok());
492 }
493
494 #[test]
495 fn test_sensor_update_missing_platform_id() {
496 let update = SensorStateUpdate {
497 platform_id: String::new(),
498 sensor: Some(valid_fixed_sensor()),
499 status: SensorStatus::Operational as i32,
500 timestamp: Some(Timestamp {
501 seconds: 1702000000,
502 nanos: 0,
503 }),
504 };
505 let err = validate_sensor_state_update(&update).unwrap_err();
506 assert!(matches!(err, ValidationError::MissingField(f) if f == "platform_id"));
507 }
508
509 #[test]
510 fn test_sensor_update_unspecified_status() {
511 let update = SensorStateUpdate {
512 platform_id: "UGV-Alpha-1".to_string(),
513 sensor: Some(valid_fixed_sensor()),
514 status: SensorStatus::Unspecified as i32,
515 timestamp: Some(Timestamp {
516 seconds: 1702000000,
517 nanos: 0,
518 }),
519 };
520 let err = validate_sensor_state_update(&update).unwrap_err();
521 assert!(matches!(err, ValidationError::InvalidValue(_)));
522 }
523}