sota/
spot.rs

1//! Statements of activations in progress.
2
3use std::{
4    fmt::{self, Display},
5    hash::{Hash, Hasher},
6    str::FromStr,
7};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use serde_with::{serde_as, DeserializeFromStr, NoneAsEmptyString, SerializeDisplay};
12use time::OffsetDateTime;
13
14use crate::{
15    callsign::Callsign,
16    summit::{HasSummit, SummitCode},
17    ParseError,
18};
19
20use super::{Mode, Notice};
21
22#[allow(clippy::upper_case_acronyms, missing_docs)]
23#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
24pub enum SpotType {
25    Normal,
26    QRT,
27    Test,
28}
29
30impl FromStr for SpotType {
31    type Err = fmt::Error;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        match s.to_lowercase().as_ref() {
35            "normal" => Ok(SpotType::Normal),
36            "qrt" => Ok(SpotType::QRT),
37            "test" => Ok(SpotType::Test),
38            _ => Err(fmt::Error),
39        }
40    }
41}
42
43impl Display for SpotType {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "{:?}", self)
46    }
47}
48
49/// A SOTA spot.
50#[serde_as]
51#[derive(Debug, Clone, Deserialize, Serialize, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct Spot {
54    /// Serial number from API.
55    pub id: usize,
56    /// Time when the spot was posted.
57    #[serde(with = "time::serde::rfc3339")]
58    pub time_stamp: OffsetDateTime,
59    /// Activator's callsign.
60    pub activator_callsign: Callsign,
61    /// Activator's name.
62    pub activator_name: String,
63    /// Poster's callsign.
64    // A good reason Callsign doesn't enforce a specific format:
65    // `RBNHOLE` is a common poster.
66    pub callsign: Callsign,
67    /// Comments by the poster.
68    #[serde_as(as = "NoneAsEmptyString")]
69    pub comments: Option<String>,
70    /// Frequency in megahertz.
71    #[serde(with = "frequency")]
72    pub frequency: Option<String>,
73    /// The mode in which the activator is operating.
74    pub mode: Mode,
75    /// Full code of the summit, e.g. "W6/CC-063".
76    pub summit_code: String,
77    /// Name of the summit, e.g. "Mount Tamalpais.".
78    pub summit_name: String,
79    /// Summit altitude in feet.
80    #[serde(rename = "AltFt")]
81    pub alt_ft: usize,
82    /// Summit altitude in meters.
83    #[serde(rename = "AltM")]
84    pub alt_m: usize,
85    /// Point value for chasing this summit.
86    pub points: usize,
87    /// Spot type.
88    pub r#type: Option<SpotType>,
89    /// A UUID by which clients should cache responses.
90    pub epoch: String,
91}
92
93impl Notice for Spot {
94    #[allow(clippy::misnamed_getters)]
95    fn callsign(&self) -> &Callsign {
96        &self.activator_callsign
97    }
98
99    fn epoch(&self) -> &str {
100        &self.epoch
101    }
102}
103
104impl HasSummit for Spot {
105    fn summit_code(&self) -> Result<SummitCode, ParseError> {
106        self.summit_code.parse()
107    }
108}
109
110// Only essential fields need be hashed/compared, to prevent duplicates
111// from things like edited comments.
112impl Hash for Spot {
113    fn hash<H: Hasher>(&self, state: &mut H) {
114        self.activator_callsign.hash(state);
115        self.summit_code.hash(state);
116        self.frequency.hash(state);
117        self.mode.hash(state);
118        self.time_stamp.hash(state);
119    }
120}
121
122impl PartialEq for Spot {
123    fn eq(&self, other: &Self) -> bool {
124        self.activator_callsign == other.activator_callsign
125            && self.summit_code == other.summit_code
126            && self.frequency == other.frequency
127            && self.mode == other.mode
128            && self.time_stamp == other.time_stamp
129    }
130}
131mod frequency {
132    //! Floats don't implement `Eq`, so frequencies must deserialize to `String`.
133    //! But API serves floats, so frequencies must serialize to `f64`.
134    use super::*;
135    use serde::{de, ser, Deserializer, Serializer};
136
137    pub(super) fn serialize<S: Serializer>(
138        freq: &Option<String>,
139        serializer: S,
140    ) -> Result<S::Ok, S::Error> {
141        match freq {
142            Some(freq) => f64::from_str(freq)
143                .map_err(ser::Error::custom)?
144                .serialize(serializer),
145            None => serializer.serialize_none(),
146        }
147    }
148
149    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
150    where
151        D: Deserializer<'de>,
152    {
153        Ok(match Value::deserialize(deserializer)? {
154            Value::Number(num) => Some(num.to_string()),
155            Value::Null => None,
156            _ => return Err(de::Error::custom("Expected number")),
157        })
158    }
159}