Skip to main content

camgrab_core/
camera.rs

1//! Camera abstraction layer for camgrab
2//!
3//! This module provides the core camera abstraction, including protocol definitions,
4//! transport types, authentication methods, and camera runtime representation.
5
6use std::fmt;
7use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// RTSP protocol variants
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "lowercase")]
16pub enum Protocol {
17    /// Standard RTSP over TCP
18    #[default]
19    Rtsp,
20    /// RTSP over TLS (secure)
21    Rtsps,
22}
23
24impl fmt::Display for Protocol {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::Rtsp => write!(f, "rtsp"),
28            Self::Rtsps => write!(f, "rtsps"),
29        }
30    }
31}
32
33/// Network transport protocol
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
35#[serde(rename_all = "lowercase")]
36pub enum Transport {
37    /// TCP transport (reliable, connection-oriented)
38    #[default]
39    Tcp,
40    /// UDP transport (faster, connectionless)
41    Udp,
42}
43
44impl fmt::Display for Transport {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Tcp => write!(f, "tcp"),
48            Self::Udp => write!(f, "udp"),
49        }
50    }
51}
52
53/// Camera stream type
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
55#[serde(rename_all = "lowercase")]
56pub enum StreamType {
57    /// Main/primary stream (typically higher resolution)
58    #[default]
59    Main,
60    /// Sub/secondary stream (typically lower resolution)
61    Sub,
62    /// Custom stream path
63    Custom(String),
64}
65
66impl fmt::Display for StreamType {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::Main => write!(f, "main"),
70            Self::Sub => write!(f, "sub"),
71            Self::Custom(path) => write!(f, "custom({path})"),
72        }
73    }
74}
75
76/// Authentication method for RTSP
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
78#[serde(rename_all = "lowercase")]
79pub enum AuthMethod {
80    /// Automatically detect authentication method
81    #[default]
82    Auto,
83    /// HTTP Basic authentication
84    Basic,
85    /// HTTP Digest authentication (more secure)
86    Digest,
87}
88
89impl fmt::Display for AuthMethod {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::Auto => write!(f, "auto"),
93            Self::Basic => write!(f, "basic"),
94            Self::Digest => write!(f, "digest"),
95        }
96    }
97}
98
99/// Runtime representation of a connected camera
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Camera {
102    /// Camera name/identifier
103    pub name: String,
104    /// Hostname or IP address
105    pub host: String,
106    /// RTSP port
107    pub port: u16,
108    /// Username for authentication
109    pub username: Option<String>,
110    /// Password for authentication
111    pub password: Option<String>,
112    /// RTSP protocol (rtsp or rtsps)
113    pub protocol: Protocol,
114    /// Network transport (tcp or udp)
115    pub transport: Transport,
116    /// Stream type (main, sub, or custom)
117    pub stream: StreamType,
118    /// Custom stream path override
119    pub custom_path: Option<String>,
120    /// Whether to enable audio in the stream
121    pub audio_enabled: bool,
122    /// Authentication method
123    pub auth_method: AuthMethod,
124    /// Connection timeout
125    pub timeout: Duration,
126}
127
128impl Default for Camera {
129    fn default() -> Self {
130        Self {
131            name: String::new(),
132            host: "localhost".to_string(),
133            port: 554,
134            username: None,
135            password: None,
136            protocol: Protocol::default(),
137            transport: Transport::default(),
138            stream: StreamType::default(),
139            custom_path: None,
140            audio_enabled: false,
141            auth_method: AuthMethod::default(),
142            timeout: Duration::from_secs(10),
143        }
144    }
145}
146
147impl Camera {
148    /// Create a new camera from configuration
149    ///
150    /// # Arguments
151    /// * `config` - The camera configuration
152    ///
153    /// # Returns
154    /// A new `Camera` instance
155    pub fn from_config(config: &crate::config::CameraConfig) -> Self {
156        Self {
157            name: config.name.clone(),
158            host: config.host.clone(),
159            port: config.port.unwrap_or(554),
160            username: config.username.clone(),
161            password: config.password.clone(),
162            protocol: config.protocol.unwrap_or_default(),
163            transport: config.transport.unwrap_or_default(),
164            stream: config.stream_type.clone().unwrap_or_default(),
165            custom_path: config.custom_path.clone(),
166            audio_enabled: config.audio_enabled.unwrap_or(false),
167            auth_method: config.auth_method.unwrap_or_default(),
168            timeout: Duration::from_secs(config.timeout_secs.unwrap_or(10)),
169        }
170    }
171
172    /// Build the full RTSP URL with credentials
173    ///
174    /// # Returns
175    /// A complete RTSP URL string in the format:
176    /// `rtsp://[username:password@]host:port/path`
177    pub fn rtsp_url(&self) -> String {
178        let auth = match (&self.username, &self.password) {
179            (Some(user), Some(pass)) => format!("{user}:{pass}@"),
180            (Some(user), None) => format!("{user}@"),
181            _ => String::new(),
182        };
183
184        format!(
185            "{}://{}{}:{}/{}",
186            self.protocol,
187            auth,
188            self.host,
189            self.port,
190            self.stream_path()
191        )
192    }
193
194    /// Build the RTSP URL with password redacted
195    ///
196    /// # Returns
197    /// An RTSP URL string with the password replaced by asterisks
198    pub fn rtsp_url_redacted(&self) -> String {
199        let auth = match (&self.username, &self.password) {
200            (Some(user), Some(_)) => format!("{user}:***@"),
201            (Some(user), None) => format!("{user}@"),
202            _ => String::new(),
203        };
204
205        format!(
206            "{}://{}{}:{}/{}",
207            self.protocol,
208            auth,
209            self.host,
210            self.port,
211            self.stream_path()
212        )
213    }
214
215    /// Get the stream path based on the stream type
216    ///
217    /// # Returns
218    /// The stream path segment of the RTSP URL
219    pub fn stream_path(&self) -> &str {
220        if let Some(custom) = &self.custom_path {
221            return custom.as_str();
222        }
223
224        match &self.stream {
225            StreamType::Main => "stream/main",
226            StreamType::Sub => "stream/sub",
227            StreamType::Custom(path) => path.as_str(),
228        }
229    }
230
231    /// Get the display name of the camera
232    ///
233    /// # Returns
234    /// The camera's configured name
235    pub fn display_name(&self) -> &str {
236        &self.name
237    }
238}
239
240/// Camera-related errors
241#[derive(Debug, Error, Clone, PartialEq, Eq)]
242pub enum CameraError {
243    /// Invalid hostname or IP address
244    #[error("Invalid host: {0}")]
245    InvalidHost(String),
246
247    /// Invalid port number
248    #[error("Invalid port: {0}")]
249    InvalidPort(u16),
250
251    /// Authentication failed
252    #[error("Authentication failed for camera: {0}")]
253    AuthenticationFailed(String),
254
255    /// Connection timeout
256    #[error("Connection timeout after {0}ms")]
257    ConnectionTimeout(u64),
258
259    /// Stream not found at the specified path
260    #[error("Stream not found: {0}")]
261    StreamNotFound(String),
262
263    /// Connection refused by the camera
264    #[error("Connection refused by camera at {0}:{1}")]
265    ConnectionRefused(String, u16),
266
267    /// Unknown error
268    #[error("Unknown camera error: {0}")]
269    Unknown(String),
270}
271
272/// Camera status for health checks
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct CameraStatus {
275    /// Whether the camera is reachable via network
276    pub reachable: bool,
277    /// Whether RTSP connection is successful
278    pub rtsp_ok: bool,
279    /// Network latency in milliseconds
280    pub latency_ms: Option<u64>,
281    /// Error if any occurred during health check
282    pub error: Option<CameraError>,
283    /// Timestamp of last health check
284    pub last_checked: DateTime<Utc>,
285}
286
287impl CameraStatus {
288    /// Create a new healthy camera status
289    pub fn healthy(latency_ms: u64) -> Self {
290        Self {
291            reachable: true,
292            rtsp_ok: true,
293            latency_ms: Some(latency_ms),
294            error: None,
295            last_checked: Utc::now(),
296        }
297    }
298
299    /// Create a new unhealthy camera status with an error
300    pub fn unhealthy(error: CameraError) -> Self {
301        Self {
302            reachable: false,
303            rtsp_ok: false,
304            latency_ms: None,
305            error: Some(error),
306            last_checked: Utc::now(),
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::config::CameraConfig;
315
316    #[test]
317    fn test_protocol_display() {
318        assert_eq!(Protocol::Rtsp.to_string(), "rtsp");
319        assert_eq!(Protocol::Rtsps.to_string(), "rtsps");
320    }
321
322    #[test]
323    fn test_transport_display() {
324        assert_eq!(Transport::Tcp.to_string(), "tcp");
325        assert_eq!(Transport::Udp.to_string(), "udp");
326    }
327
328    #[test]
329    fn test_stream_type_display() {
330        assert_eq!(StreamType::Main.to_string(), "main");
331        assert_eq!(StreamType::Sub.to_string(), "sub");
332        assert_eq!(
333            StreamType::Custom("custom/path".to_string()).to_string(),
334            "custom(custom/path)"
335        );
336    }
337
338    #[test]
339    fn test_rtsp_url_without_auth() {
340        let camera = Camera {
341            name: "test-cam".to_string(),
342            host: "192.168.1.100".to_string(),
343            port: 554,
344            username: None,
345            password: None,
346            protocol: Protocol::Rtsp,
347            transport: Transport::Tcp,
348            stream: StreamType::Main,
349            custom_path: None,
350            audio_enabled: false,
351            auth_method: AuthMethod::Auto,
352            timeout: Duration::from_secs(10),
353        };
354
355        assert_eq!(camera.rtsp_url(), "rtsp://192.168.1.100:554/stream/main");
356    }
357
358    #[test]
359    fn test_rtsp_url_with_auth() {
360        let camera = Camera {
361            name: "test-cam".to_string(),
362            host: "192.168.1.100".to_string(),
363            port: 554,
364            username: Some("admin".to_string()),
365            password: Some("password123".to_string()),
366            protocol: Protocol::Rtsp,
367            transport: Transport::Tcp,
368            stream: StreamType::Main,
369            custom_path: None,
370            audio_enabled: false,
371            auth_method: AuthMethod::Digest,
372            timeout: Duration::from_secs(10),
373        };
374
375        assert_eq!(
376            camera.rtsp_url(),
377            "rtsp://admin:password123@192.168.1.100:554/stream/main"
378        );
379    }
380
381    #[test]
382    fn test_rtsp_url_with_username_only() {
383        let camera = Camera {
384            name: "test-cam".to_string(),
385            host: "camera.local".to_string(),
386            port: 8554,
387            username: Some("viewer".to_string()),
388            password: None,
389            protocol: Protocol::Rtsps,
390            transport: Transport::Udp,
391            stream: StreamType::Sub,
392            custom_path: None,
393            audio_enabled: true,
394            auth_method: AuthMethod::Basic,
395            timeout: Duration::from_secs(15),
396        };
397
398        assert_eq!(
399            camera.rtsp_url(),
400            "rtsps://viewer@camera.local:8554/stream/sub"
401        );
402    }
403
404    #[test]
405    fn test_rtsp_url_redacted() {
406        let camera = Camera {
407            name: "secure-cam".to_string(),
408            host: "10.0.0.50".to_string(),
409            port: 554,
410            username: Some("admin".to_string()),
411            password: Some("super-secret-password".to_string()),
412            protocol: Protocol::Rtsp,
413            transport: Transport::Tcp,
414            stream: StreamType::Main,
415            custom_path: None,
416            audio_enabled: false,
417            auth_method: AuthMethod::Digest,
418            timeout: Duration::from_secs(10),
419        };
420
421        let redacted = camera.rtsp_url_redacted();
422        assert_eq!(redacted, "rtsp://admin:***@10.0.0.50:554/stream/main");
423        assert!(!redacted.contains("super-secret-password"));
424    }
425
426    #[test]
427    fn test_stream_path_main() {
428        let camera = Camera {
429            name: "test".to_string(),
430            host: "localhost".to_string(),
431            port: 554,
432            username: None,
433            password: None,
434            protocol: Protocol::Rtsp,
435            transport: Transport::Tcp,
436            stream: StreamType::Main,
437            custom_path: None,
438            audio_enabled: false,
439            auth_method: AuthMethod::Auto,
440            timeout: Duration::from_secs(10),
441        };
442
443        assert_eq!(camera.stream_path(), "stream/main");
444    }
445
446    #[test]
447    fn test_stream_path_sub() {
448        let camera = Camera {
449            name: "test".to_string(),
450            host: "localhost".to_string(),
451            port: 554,
452            username: None,
453            password: None,
454            protocol: Protocol::Rtsp,
455            transport: Transport::Tcp,
456            stream: StreamType::Sub,
457            custom_path: None,
458            audio_enabled: false,
459            auth_method: AuthMethod::Auto,
460            timeout: Duration::from_secs(10),
461        };
462
463        assert_eq!(camera.stream_path(), "stream/sub");
464    }
465
466    #[test]
467    fn test_stream_path_custom() {
468        let camera = Camera {
469            name: "test".to_string(),
470            host: "localhost".to_string(),
471            port: 554,
472            username: None,
473            password: None,
474            protocol: Protocol::Rtsp,
475            transport: Transport::Tcp,
476            stream: StreamType::Custom("live/ch00_1".to_string()),
477            custom_path: None,
478            audio_enabled: false,
479            auth_method: AuthMethod::Auto,
480            timeout: Duration::from_secs(10),
481        };
482
483        assert_eq!(camera.stream_path(), "live/ch00_1");
484    }
485
486    #[test]
487    fn test_stream_path_custom_override() {
488        let camera = Camera {
489            name: "test".to_string(),
490            host: "localhost".to_string(),
491            port: 554,
492            username: None,
493            password: None,
494            protocol: Protocol::Rtsp,
495            transport: Transport::Tcp,
496            stream: StreamType::Main,
497            custom_path: Some("override/path".to_string()),
498            audio_enabled: false,
499            auth_method: AuthMethod::Auto,
500            timeout: Duration::from_secs(10),
501        };
502
503        assert_eq!(camera.stream_path(), "override/path");
504    }
505
506    #[test]
507    fn test_from_config() {
508        let config = CameraConfig {
509            name: "front-door".to_string(),
510            host: "192.168.1.10".to_string(),
511            port: Some(8554),
512            username: Some("user".to_string()),
513            password: Some("pass".to_string()),
514            protocol: Some(Protocol::Rtsps),
515            transport: Some(Transport::Udp),
516            stream_type: Some(StreamType::Sub),
517            custom_path: Some("custom/stream".to_string()),
518            audio_enabled: Some(true),
519            auth_method: Some(AuthMethod::Digest),
520            timeout_secs: Some(20),
521        };
522
523        let camera = Camera::from_config(&config);
524
525        assert_eq!(camera.name, "front-door");
526        assert_eq!(camera.host, "192.168.1.10");
527        assert_eq!(camera.port, 8554);
528        assert_eq!(camera.username, Some("user".to_string()));
529        assert_eq!(camera.password, Some("pass".to_string()));
530        assert_eq!(camera.protocol, Protocol::Rtsps);
531        assert_eq!(camera.transport, Transport::Udp);
532        assert_eq!(camera.stream, StreamType::Sub);
533        assert_eq!(camera.custom_path, Some("custom/stream".to_string()));
534        assert!(camera.audio_enabled);
535        assert_eq!(camera.auth_method, AuthMethod::Digest);
536        assert_eq!(camera.timeout, Duration::from_secs(20));
537    }
538
539    #[test]
540    fn test_from_config_with_defaults() {
541        let config = CameraConfig {
542            name: "basic-cam".to_string(),
543            host: "camera.local".to_string(),
544            port: None,
545            username: None,
546            password: None,
547            protocol: None,
548            transport: None,
549            stream_type: None,
550            custom_path: None,
551            audio_enabled: None,
552            auth_method: None,
553            timeout_secs: None,
554        };
555
556        let camera = Camera::from_config(&config);
557
558        assert_eq!(camera.name, "basic-cam");
559        assert_eq!(camera.host, "camera.local");
560        assert_eq!(camera.port, 554); // default
561        assert_eq!(camera.username, None);
562        assert_eq!(camera.password, None);
563        assert_eq!(camera.protocol, Protocol::Rtsp); // default
564        assert_eq!(camera.transport, Transport::Tcp); // default
565        assert_eq!(camera.stream, StreamType::Main); // default
566        assert_eq!(camera.custom_path, None);
567        assert!(!camera.audio_enabled); // default
568        assert_eq!(camera.auth_method, AuthMethod::Auto); // default
569        assert_eq!(camera.timeout, Duration::from_secs(10)); // default
570    }
571
572    #[test]
573    fn test_display_name() {
574        let camera = Camera {
575            name: "my-camera".to_string(),
576            host: "localhost".to_string(),
577            port: 554,
578            username: None,
579            password: None,
580            protocol: Protocol::Rtsp,
581            transport: Transport::Tcp,
582            stream: StreamType::Main,
583            custom_path: None,
584            audio_enabled: false,
585            auth_method: AuthMethod::Auto,
586            timeout: Duration::from_secs(10),
587        };
588
589        assert_eq!(camera.display_name(), "my-camera");
590    }
591
592    #[test]
593    fn test_camera_status_healthy() {
594        let status = CameraStatus::healthy(50);
595        assert!(status.reachable);
596        assert!(status.rtsp_ok);
597        assert_eq!(status.latency_ms, Some(50));
598        assert!(status.error.is_none());
599    }
600
601    #[test]
602    fn test_camera_status_unhealthy() {
603        let error = CameraError::ConnectionTimeout(5000);
604        let status = CameraStatus::unhealthy(error.clone());
605        assert!(!status.reachable);
606        assert!(!status.rtsp_ok);
607        assert!(status.latency_ms.is_none());
608        assert_eq!(status.error, Some(error));
609    }
610
611    #[test]
612    fn test_camera_error_display() {
613        let error = CameraError::InvalidHost("bad-host".to_string());
614        assert_eq!(error.to_string(), "Invalid host: bad-host");
615
616        let error = CameraError::ConnectionTimeout(10000);
617        assert_eq!(error.to_string(), "Connection timeout after 10000ms");
618
619        let error = CameraError::ConnectionRefused("192.168.1.1".to_string(), 554);
620        assert_eq!(
621            error.to_string(),
622            "Connection refused by camera at 192.168.1.1:554"
623        );
624    }
625}