use serde::{Deserialize, Deserializer, Serialize};
use crate::error::{Error, Result};
pub type Mcc = u16;
pub type Mnc = u16;
pub type Lac = u32;
pub type CellId = u64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[non_exhaustive]
pub enum Radio {
Gsm,
Umts,
Lte,
#[serde(rename = "NBIOT")]
NbIot,
Nr,
Cdma,
}
impl Radio {
pub fn as_api_str(self) -> &'static str {
match self {
Self::Gsm => "GSM",
Self::Umts => "UMTS",
Self::Lte => "LTE",
Self::NbIot => "NBIOT",
Self::Nr => "NR",
Self::Cdma => "CDMA",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct CellKey {
pub mcc: Mcc,
pub mnc: Mnc,
pub lac: Lac,
pub cell_id: CellId,
pub radio: Option<Radio>,
}
impl CellKey {
pub fn new(mcc: Mcc, mnc: Mnc, lac: Lac, cell_id: CellId) -> Self {
Self { mcc, mnc, lac, cell_id, radio: None }
}
pub fn with_radio(mut self, radio: Radio) -> Self {
self.radio = Some(radio);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bbox {
lat_min: f64,
lon_min: f64,
lat_max: f64,
lon_max: f64,
}
impl Bbox {
pub fn new(lat_min: f64, lon_min: f64, lat_max: f64, lon_max: f64) -> Result<Self> {
if !lat_min.is_finite() || !lat_max.is_finite() || !lon_min.is_finite() || !lon_max.is_finite() {
return Err(Error::InvalidInput("bbox coordinates must be finite".into()));
}
if !(-90.0..=90.0).contains(&lat_min) || !(-90.0..=90.0).contains(&lat_max) {
return Err(Error::InvalidInput("latitude out of range".into()));
}
if !(-180.0..=180.0).contains(&lon_min) || !(-180.0..=180.0).contains(&lon_max) {
return Err(Error::InvalidInput("longitude out of range".into()));
}
if lat_min >= lat_max || lon_min >= lon_max {
return Err(Error::InvalidInput(
"min coordinates must be strictly less than max".into(),
));
}
Ok(Self { lat_min, lon_min, lat_max, lon_max })
}
pub fn lat_min(&self) -> f64 { self.lat_min }
pub fn lon_min(&self) -> f64 { self.lon_min }
pub fn lat_max(&self) -> f64 { self.lat_max }
pub fn lon_max(&self) -> f64 { self.lon_max }
pub fn to_query_value(self) -> String {
format!(
"{},{},{},{}",
format_coordinate(self.lat_min),
format_coordinate(self.lon_min),
format_coordinate(self.lat_max),
format_coordinate(self.lon_max),
)
}
}
pub(crate) fn format_coordinate(v: f64) -> String {
let mut s = format!("{v:.7}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
s
}
fn deserialize_bool_from_int<'de, D>(d: D) -> std::result::Result<bool, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error as _;
let v = serde_json::Value::deserialize(d)?;
match v {
serde_json::Value::Bool(b) => Ok(b),
serde_json::Value::Number(n) => match n.as_u64() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => Err(D::Error::custom("expected 0 or 1 for boolean field")),
},
serde_json::Value::String(s) => match s.as_str() {
"0" => Ok(false),
"1" => Ok(true),
other => Err(D::Error::custom(format!("expected 0/1, got {other:?}"))),
},
other => Err(D::Error::custom(format!("expected boolean, got {other}"))),
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Cell {
pub lat: f64,
pub lon: f64,
pub mcc: Mcc,
pub mnc: Mnc,
pub lac: Lac,
#[serde(rename = "cellid", alias = "cellId")]
pub cell_id: CellId,
#[serde(default)]
pub range: u32,
#[serde(default)]
pub samples: u32,
#[serde(default, deserialize_with = "deserialize_bool_from_int")]
pub changeable: bool,
#[serde(rename = "averageSignalStrength", default)]
pub avg_signal: i32,
#[serde(default)]
pub radio: Option<Radio>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[non_exhaustive]
pub struct CellCount {
pub count: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct Measurement {
pub lat: f64,
pub lon: f64,
pub mcc: Mcc,
pub mnc: Mnc,
pub lac: Lac,
#[serde(rename = "cellid")]
pub cell_id: CellId,
#[serde(rename = "act", serialize_with = "serialize_radio_as_str")]
pub radio: Radio,
#[serde(skip_serializing_if = "Option::is_none")]
pub signal: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub measured_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub direction: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ta: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub psc: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tac: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pci: Option<u16>,
}
fn serialize_radio_as_str<S>(r: &Radio, s: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
s.serialize_str(r.as_api_str())
}
impl Measurement {
pub fn new(
lat: f64,
lon: f64,
mcc: Mcc,
mnc: Mnc,
lac: Lac,
cell_id: CellId,
radio: Radio,
) -> Result<Self> {
if !lat.is_finite() || !lon.is_finite() {
return Err(Error::InvalidInput("lat/lon must be finite".into()));
}
Ok(Self {
lat, lon, mcc, mnc, lac, cell_id, radio,
signal: None, measured_at: None, rating: None, speed: None,
direction: None, ta: None, psc: None, tac: None, pci: None,
})
}
pub fn with_signal(mut self, signal: i32) -> Self {
self.signal = Some(signal);
self
}
pub fn with_measured_at(mut self, ts: impl Into<String>) -> Self {
self.measured_at = Some(ts.into());
self
}
pub fn with_rating(mut self, rating: u32) -> Self {
self.rating = Some(rating);
self
}
pub fn with_speed(mut self, speed: f32) -> Self {
self.speed = Some(speed);
self
}
pub fn with_direction(mut self, direction: f32) -> Self {
self.direction = Some(direction);
self
}
pub fn validate(&self) -> Result<()> {
for (name, v) in [("speed", self.speed), ("direction", self.direction)] {
if let Some(v) = v {
if !v.is_finite() {
return Err(Error::InvalidInput(format!("{name} must be finite")));
}
}
}
Ok(())
}
pub fn with_ta(mut self, ta: u32) -> Self {
self.ta = Some(ta);
self
}
pub fn with_psc(mut self, psc: u16) -> Self {
self.psc = Some(psc);
self
}
pub fn with_tac(mut self, tac: u32) -> Self {
self.tac = Some(tac);
self
}
pub fn with_pci(mut self, pci: u16) -> Self {
self.pci = Some(pci);
self
}
}
#[derive(Debug, Clone, Default, Serialize)]
#[non_exhaustive]
pub struct MeasurementsPayload {
pub measurements: Vec<Measurement>,
}
impl MeasurementsPayload {
pub fn new() -> Self {
Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn bbox_validates_ranges() {
assert!(Bbox::new(0.0, 0.0, 1.0, 1.0).is_ok());
assert!(Bbox::new(-91.0, 0.0, 0.0, 1.0).is_err());
assert!(Bbox::new(1.0, 0.0, 0.0, 1.0).is_err());
assert!(Bbox::new(f64::NAN, 0.0, 1.0, 1.0).is_err());
}
#[test]
fn bbox_query_format() {
let b = Bbox::new(10.0, 20.0, 11.0, 21.0).unwrap();
assert_eq!(b.to_query_value(), "10,20,11,21");
}
#[test]
fn radio_serializes_uppercase() {
assert_eq!(serde_json::to_string(&Radio::NbIot).unwrap(), "\"NBIOT\"");
assert_eq!(serde_json::to_string(&Radio::Lte).unwrap(), "\"LTE\"");
}
#[test]
fn cell_changeable_accepts_int_and_bool() {
let body = r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":1,"changeable":1}"#;
let c: Cell = serde_json::from_str(body).unwrap();
assert!(c.changeable);
let body = r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":1,"changeable":false}"#;
let c: Cell = serde_json::from_str(body).unwrap();
assert!(!c.changeable);
}
#[test]
fn cell_id_alias() {
for body in [
r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellid":42}"#,
r#"{"lat":1.0,"lon":2.0,"mcc":1,"mnc":1,"lac":1,"cellId":42}"#,
] {
let c: Cell = serde_json::from_str(body).unwrap();
assert_eq!(c.cell_id, 42);
}
}
#[test]
fn measurement_serializes_act_and_cellid() {
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte).unwrap();
let json = serde_json::to_value(&m).unwrap();
assert_eq!(json["cellid"], 42);
assert_eq!(json["act"], "LTE");
assert!(json.get("signal").is_none(), "None fields must be skipped");
}
#[test]
fn measurement_rejects_non_finite_position() {
assert!(Measurement::new(f64::NAN, 0.0, 1, 1, 1, 1, Radio::Lte).is_err());
assert!(Measurement::new(0.0, f64::INFINITY, 1, 1, 1, 1, Radio::Lte).is_err());
}
#[test]
fn measurement_with_setters_compose() {
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_signal(-95)
.with_rating(50)
.with_ta(3)
.with_speed(12.5);
assert_eq!(m.signal, Some(-95));
assert_eq!(m.rating, Some(50));
assert_eq!(m.ta, Some(3));
assert_eq!(m.speed, Some(12.5));
}
#[test]
fn measurement_validate_catches_non_finite_optional_fields() {
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_speed(f32::NAN);
assert!(m.validate().is_err());
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_direction(f32::INFINITY);
assert!(m.validate().is_err());
let m = Measurement::new(1.0, 2.0, 250, 1, 7, 42, Radio::Lte)
.unwrap()
.with_speed(12.5)
.with_direction(180.0);
assert!(m.validate().is_ok());
}
#[test]
fn format_coordinate_avoids_scientific() {
assert_eq!(format_coordinate(1e-7), "0.0000001");
assert_eq!(format_coordinate(1.0), "1");
assert_eq!(format_coordinate(55.7558), "55.7558");
assert_eq!(format_coordinate(-180.0), "-180");
}
proptest! {
#[test]
fn bbox_invariant_holds_when_constructor_succeeds(
lat_min in -90.0f64..90.0,
lat_max in -90.0f64..90.0,
lon_min in -180.0f64..180.0,
lon_max in -180.0f64..180.0,
) {
if let Ok(b) = Bbox::new(lat_min, lon_min, lat_max, lon_max) {
prop_assert!(b.lat_min() < b.lat_max());
prop_assert!(b.lon_min() < b.lon_max());
prop_assert!((-90.0..=90.0).contains(&b.lat_min()));
prop_assert!((-90.0..=90.0).contains(&b.lat_max()));
prop_assert!((-180.0..=180.0).contains(&b.lon_min()));
prop_assert!((-180.0..=180.0).contains(&b.lon_max()));
}
}
#[test]
fn bbox_rejects_inverted(
lat in -90.0f64..90.0,
lon in -180.0f64..180.0,
) {
prop_assert!(Bbox::new(lat, lon, lat, lon).is_err());
prop_assert!(Bbox::new(lat + 1.0, lon, lat, lon + 1.0).is_err());
}
}
}