1use std::fmt;
7use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[serde(rename_all = "lowercase")]
16pub enum Protocol {
17 #[default]
19 Rtsp,
20 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
35#[serde(rename_all = "lowercase")]
36pub enum Transport {
37 #[default]
39 Tcp,
40 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
55#[serde(rename_all = "lowercase")]
56pub enum StreamType {
57 #[default]
59 Main,
60 Sub,
62 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
78#[serde(rename_all = "lowercase")]
79pub enum AuthMethod {
80 #[default]
82 Auto,
83 Basic,
85 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#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Camera {
102 pub name: String,
104 pub host: String,
106 pub port: u16,
108 pub username: Option<String>,
110 pub password: Option<String>,
112 pub protocol: Protocol,
114 pub transport: Transport,
116 pub stream: StreamType,
118 pub custom_path: Option<String>,
120 pub audio_enabled: bool,
122 pub auth_method: AuthMethod,
124 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 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 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 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 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 pub fn display_name(&self) -> &str {
236 &self.name
237 }
238}
239
240#[derive(Debug, Error, Clone, PartialEq, Eq)]
242pub enum CameraError {
243 #[error("Invalid host: {0}")]
245 InvalidHost(String),
246
247 #[error("Invalid port: {0}")]
249 InvalidPort(u16),
250
251 #[error("Authentication failed for camera: {0}")]
253 AuthenticationFailed(String),
254
255 #[error("Connection timeout after {0}ms")]
257 ConnectionTimeout(u64),
258
259 #[error("Stream not found: {0}")]
261 StreamNotFound(String),
262
263 #[error("Connection refused by camera at {0}:{1}")]
265 ConnectionRefused(String, u16),
266
267 #[error("Unknown camera error: {0}")]
269 Unknown(String),
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct CameraStatus {
275 pub reachable: bool,
277 pub rtsp_ok: bool,
279 pub latency_ms: Option<u64>,
281 pub error: Option<CameraError>,
283 pub last_checked: DateTime<Utc>,
285}
286
287impl CameraStatus {
288 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 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); assert_eq!(camera.username, None);
562 assert_eq!(camera.password, None);
563 assert_eq!(camera.protocol, Protocol::Rtsp); assert_eq!(camera.transport, Transport::Tcp); assert_eq!(camera.stream, StreamType::Main); assert_eq!(camera.custom_path, None);
567 assert!(!camera.audio_enabled); assert_eq!(camera.auth_method, AuthMethod::Auto); assert_eq!(camera.timeout, Duration::from_secs(10)); }
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}