Skip to main content

seedlink_rs_client/
state.rs

1use std::time::Duration;
2
3use seedlink_rs_protocol::{PayloadFormat, PayloadSubformat, RawFrame, SequenceNumber};
4
5/// Client connection state machine.
6///
7/// Transitions: `Disconnected` → `Connected` → `Configured` → `Streaming` → `Disconnected`.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum ClientState {
10    /// Not connected to any server.
11    Disconnected,
12    /// TCP connected and HELLO exchanged; ready for STATION/SELECT.
13    Connected,
14    /// At least one STATION/DATA configured; ready for END or FETCH.
15    Configured,
16    /// Binary frame streaming active after END or FETCH.
17    Streaming,
18}
19
20impl ClientState {
21    /// Returns the state name as a static string.
22    pub fn as_str(&self) -> &'static str {
23        match self {
24            Self::Disconnected => "Disconnected",
25            Self::Connected => "Connected",
26            Self::Configured => "Configured",
27            Self::Streaming => "Streaming",
28        }
29    }
30}
31
32/// Configuration for [`SeedLinkClient`](crate::SeedLinkClient) connections.
33pub struct ClientConfig {
34    /// Timeout for the initial TCP connection. Default: 10 seconds.
35    pub connect_timeout: Duration,
36    /// Timeout for individual read operations (lines and frames). Default: 30 seconds.
37    pub read_timeout: Duration,
38    /// Whether to attempt SeedLink v4 negotiation. Default: `true`.
39    pub prefer_v4: bool,
40}
41
42impl Default for ClientConfig {
43    fn default() -> Self {
44        Self {
45            connect_timeout: Duration::from_secs(10),
46            read_timeout: Duration::from_secs(30),
47            prefer_v4: true,
48        }
49    }
50}
51
52/// Information about the connected SeedLink server, parsed from HELLO.
53#[derive(Clone, Debug)]
54pub struct ServerInfo {
55    /// Server software name (e.g., `"SeedLink"`).
56    pub software: String,
57    /// Server version string (e.g., `"v3.1"`).
58    pub version: String,
59    /// Server organization line.
60    pub organization: String,
61    /// Advertised capabilities (e.g., `["SLPROTO:4.0", "SLPROTO:3.1"]`).
62    pub capabilities: Vec<String>,
63}
64
65/// Network + station identifier used as a key for sequence tracking.
66#[derive(Clone, Debug, PartialEq, Eq, Hash)]
67pub struct StationKey {
68    /// FDSN network code (e.g., `"IU"`).
69    pub network: String,
70    /// Station code (e.g., `"ANMO"`).
71    pub station: String,
72}
73
74/// An owned SeedLink frame with its payload copied to the heap.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum OwnedFrame {
77    /// SeedLink v3 frame (8-byte header + 512-byte miniSEED).
78    V3 {
79        /// 6-digit hex sequence number.
80        sequence: SequenceNumber,
81        /// miniSEED v2 record (512 bytes).
82        payload: Vec<u8>,
83    },
84    /// SeedLink v4 frame with variable-length payload.
85    V4 {
86        /// Payload format indicator.
87        format: PayloadFormat,
88        /// Payload sub-format indicator.
89        subformat: PayloadSubformat,
90        /// 20-digit decimal sequence number.
91        sequence: SequenceNumber,
92        /// Station identifier (e.g., `"IU_ANMO"`).
93        station_id: String,
94        /// Payload bytes.
95        payload: Vec<u8>,
96    },
97}
98
99impl OwnedFrame {
100    /// Returns the sequence number of this frame.
101    pub fn sequence(&self) -> SequenceNumber {
102        match self {
103            Self::V3 { sequence, .. } | Self::V4 { sequence, .. } => *sequence,
104        }
105    }
106
107    /// Returns the payload bytes of this frame.
108    pub fn payload(&self) -> &[u8] {
109        match self {
110            Self::V3 { payload, .. } | Self::V4 { payload, .. } => payload,
111        }
112    }
113
114    /// Extract the station key (network + station) from the frame.
115    ///
116    /// For V3, parses station (bytes 8–12) and network (bytes 18–19) from the
117    /// miniSEED payload header. For V4, splits `station_id` on `'_'`.
118    ///
119    /// Returns `None` if the payload is too short or station info is unreadable.
120    pub fn station_key(&self) -> Option<StationKey> {
121        match self {
122            Self::V3 { payload, .. } => {
123                if payload.len() >= 20 {
124                    let station = std::str::from_utf8(&payload[8..13]).ok()?.trim().to_owned();
125                    let network = std::str::from_utf8(&payload[18..20])
126                        .ok()?
127                        .trim()
128                        .to_owned();
129                    if !station.is_empty() && !network.is_empty() {
130                        Some(StationKey { network, station })
131                    } else {
132                        None
133                    }
134                } else {
135                    None
136                }
137            }
138            Self::V4 { station_id, .. } => {
139                station_id
140                    .split_once('_')
141                    .map(|(network, station)| StationKey {
142                        network: network.to_owned(),
143                        station: station.to_owned(),
144                    })
145            }
146        }
147    }
148
149    /// Decode the payload as a miniSEED record.
150    ///
151    /// Delegates to [`RawFrame::decode()`] on a borrowed view of this frame.
152    pub fn decode(&self) -> seedlink_rs_protocol::Result<seedlink_rs_protocol::DataFrame> {
153        self.as_raw_frame().decode()
154    }
155
156    fn as_raw_frame(&self) -> RawFrame<'_> {
157        match self {
158            Self::V3 { sequence, payload } => RawFrame::V3 {
159                sequence: *sequence,
160                payload,
161            },
162            Self::V4 {
163                format,
164                subformat,
165                sequence,
166                station_id,
167                payload,
168            } => RawFrame::V4 {
169                format: *format,
170                subformat: *subformat,
171                sequence: *sequence,
172                station_id,
173                payload,
174            },
175        }
176    }
177}
178
179impl<'a> From<RawFrame<'a>> for OwnedFrame {
180    fn from(raw: RawFrame<'a>) -> Self {
181        match raw {
182            RawFrame::V3 { sequence, payload } => Self::V3 {
183                sequence,
184                payload: payload.to_vec(),
185            },
186            RawFrame::V4 {
187                format,
188                subformat,
189                sequence,
190                station_id,
191                payload,
192            } => Self::V4 {
193                format,
194                subformat,
195                sequence,
196                station_id: station_id.to_owned(),
197                payload: payload.to_vec(),
198            },
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn decode_zeroed_payload_returns_err() {
209        let frame = OwnedFrame::V3 {
210            sequence: SequenceNumber::new(1),
211            payload: vec![0u8; 512],
212        };
213        assert!(frame.decode().is_err());
214    }
215
216    #[test]
217    fn as_raw_frame_roundtrip() {
218        let frame = OwnedFrame::V3 {
219            sequence: SequenceNumber::new(42),
220            payload: vec![0xAA; 512],
221        };
222        let raw = frame.as_raw_frame();
223        assert_eq!(raw.sequence(), SequenceNumber::new(42));
224        assert_eq!(raw.payload().len(), 512);
225    }
226}