use super::{ValidationError, ValidationResult};
use crate::sensor::v1::{
FieldOfView, GimbalLimits, GimbalState, SensorMountType, SensorOrientation, SensorSpec,
SensorStateUpdate, SensorStatus,
};
pub fn validate_sensor_orientation(orientation: &SensorOrientation) -> ValidationResult<()> {
if orientation.bearing_offset_deg < 0.0 || orientation.bearing_offset_deg >= 360.0 {
return Err(ValidationError::InvalidValue(format!(
"bearing_offset_deg {} must be in range [0, 360)",
orientation.bearing_offset_deg
)));
}
if orientation.elevation_offset_deg < -90.0 || orientation.elevation_offset_deg > 90.0 {
return Err(ValidationError::InvalidValue(format!(
"elevation_offset_deg {} must be in range [-90, 90]",
orientation.elevation_offset_deg
)));
}
if orientation.roll_offset_deg < -180.0 || orientation.roll_offset_deg > 180.0 {
return Err(ValidationError::InvalidValue(format!(
"roll_offset_deg {} must be in range [-180, 180]",
orientation.roll_offset_deg
)));
}
Ok(())
}
pub fn validate_field_of_view(fov: &FieldOfView) -> ValidationResult<()> {
if fov.horizontal_deg <= 0.0 || fov.horizontal_deg >= 360.0 {
return Err(ValidationError::InvalidValue(format!(
"horizontal_deg {} must be in range (0, 360)",
fov.horizontal_deg
)));
}
if fov.vertical_deg <= 0.0 || fov.vertical_deg >= 180.0 {
return Err(ValidationError::InvalidValue(format!(
"vertical_deg {} must be in range (0, 180)",
fov.vertical_deg
)));
}
if fov.max_range_m < 0.0 {
return Err(ValidationError::InvalidValue(
"max_range_m must be non-negative".to_string(),
));
}
Ok(())
}
pub fn validate_gimbal_limits(limits: &GimbalLimits) -> ValidationResult<()> {
if limits.pan_min_deg > limits.pan_max_deg {
return Err(ValidationError::ConstraintViolation(
"pan_min_deg must be <= pan_max_deg".to_string(),
));
}
if limits.tilt_min_deg > limits.tilt_max_deg {
return Err(ValidationError::ConstraintViolation(
"tilt_min_deg must be <= tilt_max_deg".to_string(),
));
}
if limits.roll_min_deg > limits.roll_max_deg {
return Err(ValidationError::ConstraintViolation(
"roll_min_deg must be <= roll_max_deg".to_string(),
));
}
if limits.zoom_min <= 0.0 {
return Err(ValidationError::InvalidValue(
"zoom_min must be positive".to_string(),
));
}
if limits.zoom_max < limits.zoom_min {
return Err(ValidationError::ConstraintViolation(
"zoom_max must be >= zoom_min".to_string(),
));
}
Ok(())
}
pub fn validate_gimbal_state(
state: &GimbalState,
limits: Option<&GimbalLimits>,
) -> ValidationResult<()> {
if state.zoom <= 0.0 {
return Err(ValidationError::InvalidValue(
"zoom must be positive".to_string(),
));
}
if let Some(limits) = limits {
if state.pan_deg < limits.pan_min_deg || state.pan_deg > limits.pan_max_deg {
return Err(ValidationError::ConstraintViolation(format!(
"pan_deg {} must be within limits [{}, {}]",
state.pan_deg, limits.pan_min_deg, limits.pan_max_deg
)));
}
if state.tilt_deg < limits.tilt_min_deg || state.tilt_deg > limits.tilt_max_deg {
return Err(ValidationError::ConstraintViolation(format!(
"tilt_deg {} must be within limits [{}, {}]",
state.tilt_deg, limits.tilt_min_deg, limits.tilt_max_deg
)));
}
if state.zoom < limits.zoom_min || state.zoom > limits.zoom_max {
return Err(ValidationError::ConstraintViolation(format!(
"zoom {} must be within limits [{}, {}]",
state.zoom, limits.zoom_min, limits.zoom_max
)));
}
}
Ok(())
}
pub fn validate_sensor_spec(spec: &SensorSpec) -> ValidationResult<()> {
if spec.sensor_id.is_empty() {
return Err(ValidationError::MissingField("sensor_id".to_string()));
}
if spec.name.is_empty() {
return Err(ValidationError::MissingField("name".to_string()));
}
if spec.mount_type == SensorMountType::Unspecified as i32 {
return Err(ValidationError::InvalidValue(
"mount_type must be specified".to_string(),
));
}
if let Some(ref orientation) = spec.base_orientation {
validate_sensor_orientation(orientation)?;
}
let fov = spec
.field_of_view
.as_ref()
.ok_or_else(|| ValidationError::MissingField("field_of_view".to_string()))?;
validate_field_of_view(fov)?;
let is_fixed = spec.mount_type == SensorMountType::Fixed as i32;
if !is_fixed {
if spec.gimbal_limits.is_none() {
return Err(ValidationError::MissingField(
"gimbal_limits (required for non-fixed mount types)".to_string(),
));
}
if let Some(ref limits) = spec.gimbal_limits {
validate_gimbal_limits(limits)?;
}
if let Some(ref state) = spec.current_state {
validate_gimbal_state(state, spec.gimbal_limits.as_ref())?;
}
}
if spec.resolution_width > 0 && spec.resolution_height == 0 {
return Err(ValidationError::InvalidValue(
"resolution_height must be positive when resolution_width is set".to_string(),
));
}
if spec.resolution_height > 0 && spec.resolution_width == 0 {
return Err(ValidationError::InvalidValue(
"resolution_width must be positive when resolution_height is set".to_string(),
));
}
if spec.frame_rate_fps < 0.0 {
return Err(ValidationError::InvalidValue(
"frame_rate_fps must be non-negative".to_string(),
));
}
Ok(())
}
pub fn validate_sensor_state_update(update: &SensorStateUpdate) -> ValidationResult<()> {
if update.platform_id.is_empty() {
return Err(ValidationError::MissingField("platform_id".to_string()));
}
let sensor = update
.sensor
.as_ref()
.ok_or_else(|| ValidationError::MissingField("sensor".to_string()))?;
validate_sensor_spec(sensor)?;
if update.status == SensorStatus::Unspecified as i32 {
return Err(ValidationError::InvalidValue(
"status must be specified".to_string(),
));
}
if update.timestamp.is_none() {
return Err(ValidationError::MissingField("timestamp".to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::v1::Timestamp;
use crate::sensor::v1::SensorModality;
fn valid_fixed_sensor() -> SensorSpec {
SensorSpec {
sensor_id: "eo-main".to_string(),
name: "Main EO Camera".to_string(),
mount_type: SensorMountType::Fixed as i32,
base_orientation: Some(SensorOrientation {
bearing_offset_deg: 0.0, elevation_offset_deg: 0.0, roll_offset_deg: 0.0,
}),
field_of_view: Some(FieldOfView {
horizontal_deg: 62.0,
vertical_deg: 48.0,
diagonal_deg: 0.0,
max_range_m: 500.0,
}),
modality: SensorModality::Eo as i32,
resolution_width: 1920,
resolution_height: 1080,
frame_rate_fps: 30.0,
gimbal_limits: None, current_state: None,
updated_at: None,
}
}
fn valid_ptz_sensor() -> SensorSpec {
SensorSpec {
sensor_id: "ptz-tower".to_string(),
name: "Tower PTZ Camera".to_string(),
mount_type: SensorMountType::Ptz as i32,
base_orientation: Some(SensorOrientation {
bearing_offset_deg: 0.0,
elevation_offset_deg: 0.0,
roll_offset_deg: 0.0,
}),
field_of_view: Some(FieldOfView {
horizontal_deg: 45.0,
vertical_deg: 35.0,
diagonal_deg: 0.0,
max_range_m: 1000.0,
}),
modality: SensorModality::Eo as i32,
resolution_width: 3840,
resolution_height: 2160,
frame_rate_fps: 30.0,
gimbal_limits: Some(GimbalLimits {
pan_min_deg: -180.0,
pan_max_deg: 180.0,
tilt_min_deg: -30.0,
tilt_max_deg: 90.0,
roll_min_deg: 0.0,
roll_max_deg: 0.0,
zoom_min: 1.0,
zoom_max: 30.0,
pan_rate_max: 45.0,
tilt_rate_max: 30.0,
}),
current_state: Some(GimbalState {
pan_deg: 45.0,
tilt_deg: 15.0,
roll_deg: 0.0,
zoom: 2.0,
tracking: false,
tracked_target_id: String::new(),
}),
updated_at: None,
}
}
#[test]
fn test_valid_fixed_sensor() {
let sensor = valid_fixed_sensor();
assert!(validate_sensor_spec(&sensor).is_ok());
}
#[test]
fn test_valid_ptz_sensor() {
let sensor = valid_ptz_sensor();
assert!(validate_sensor_spec(&sensor).is_ok());
}
#[test]
fn test_missing_sensor_id() {
let mut sensor = valid_fixed_sensor();
sensor.sensor_id = String::new();
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::MissingField(f) if f == "sensor_id"));
}
#[test]
fn test_missing_name() {
let mut sensor = valid_fixed_sensor();
sensor.name = String::new();
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::MissingField(f) if f == "name"));
}
#[test]
fn test_unspecified_mount_type() {
let mut sensor = valid_fixed_sensor();
sensor.mount_type = SensorMountType::Unspecified as i32;
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::InvalidValue(_)));
}
#[test]
fn test_missing_fov() {
let mut sensor = valid_fixed_sensor();
sensor.field_of_view = None;
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::MissingField(f) if f == "field_of_view"));
}
#[test]
fn test_ptz_without_gimbal_limits() {
let mut sensor = valid_ptz_sensor();
sensor.gimbal_limits = None;
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::MissingField(_)));
}
#[test]
fn test_invalid_bearing() {
let mut sensor = valid_fixed_sensor();
sensor.base_orientation = Some(SensorOrientation {
bearing_offset_deg: 400.0, elevation_offset_deg: 0.0,
roll_offset_deg: 0.0,
});
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::InvalidValue(_)));
}
#[test]
fn test_invalid_elevation() {
let mut sensor = valid_fixed_sensor();
sensor.base_orientation = Some(SensorOrientation {
bearing_offset_deg: 0.0,
elevation_offset_deg: 100.0, roll_offset_deg: 0.0,
});
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::InvalidValue(_)));
}
#[test]
fn test_invalid_horizontal_fov() {
let mut sensor = valid_fixed_sensor();
sensor.field_of_view = Some(FieldOfView {
horizontal_deg: 0.0, vertical_deg: 48.0,
diagonal_deg: 0.0,
max_range_m: 500.0,
});
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::InvalidValue(_)));
}
#[test]
fn test_invalid_gimbal_pan_range() {
let mut sensor = valid_ptz_sensor();
sensor.gimbal_limits = Some(GimbalLimits {
pan_min_deg: 100.0,
pan_max_deg: -100.0, tilt_min_deg: -30.0,
tilt_max_deg: 90.0,
roll_min_deg: 0.0,
roll_max_deg: 0.0,
zoom_min: 1.0,
zoom_max: 30.0,
pan_rate_max: 45.0,
tilt_rate_max: 30.0,
});
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::ConstraintViolation(_)));
}
#[test]
fn test_gimbal_state_outside_limits() {
let mut sensor = valid_ptz_sensor();
sensor.current_state = Some(GimbalState {
pan_deg: 200.0, tilt_deg: 15.0,
roll_deg: 0.0,
zoom: 2.0,
tracking: false,
tracked_target_id: String::new(),
});
let err = validate_sensor_spec(&sensor).unwrap_err();
assert!(matches!(err, ValidationError::ConstraintViolation(_)));
}
#[test]
fn test_valid_sensor_state_update() {
let update = SensorStateUpdate {
platform_id: "UGV-Alpha-1".to_string(),
sensor: Some(valid_fixed_sensor()),
status: SensorStatus::Operational as i32,
timestamp: Some(Timestamp {
seconds: 1702000000,
nanos: 0,
}),
};
assert!(validate_sensor_state_update(&update).is_ok());
}
#[test]
fn test_sensor_update_missing_platform_id() {
let update = SensorStateUpdate {
platform_id: String::new(),
sensor: Some(valid_fixed_sensor()),
status: SensorStatus::Operational as i32,
timestamp: Some(Timestamp {
seconds: 1702000000,
nanos: 0,
}),
};
let err = validate_sensor_state_update(&update).unwrap_err();
assert!(matches!(err, ValidationError::MissingField(f) if f == "platform_id"));
}
#[test]
fn test_sensor_update_unspecified_status() {
let update = SensorStateUpdate {
platform_id: "UGV-Alpha-1".to_string(),
sensor: Some(valid_fixed_sensor()),
status: SensorStatus::Unspecified as i32,
timestamp: Some(Timestamp {
seconds: 1702000000,
nanos: 0,
}),
};
let err = validate_sensor_state_update(&update).unwrap_err();
assert!(matches!(err, ValidationError::InvalidValue(_)));
}
}