diff --git a/Cargo.lock b/Cargo.lock index 1ccfc1f..940aff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1700,6 +1700,14 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "datalink" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -3934,6 +3942,7 @@ version = "0.1.0" dependencies = [ "bevy", "components", + "datalink", "rand 0.8.5", ] diff --git a/Cargo.toml b/Cargo.toml index 25adc3c..832e33e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/yachtpit", "crates/yachtpit/mobile", "crates/systems", "crates/components"] +members = ["crates/yachtpit", "crates/yachtpit/mobile", "crates/systems", "crates/components", "crates/datalink"] resolver = "2" default-members = [ diff --git a/crates/datalink/Cargo.toml b/crates/datalink/Cargo.toml new file mode 100644 index 0000000..89a745b --- /dev/null +++ b/crates/datalink/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "datalink" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" \ No newline at end of file diff --git a/crates/datalink/src/lib.rs b/crates/datalink/src/lib.rs new file mode 100644 index 0000000..b500252 --- /dev/null +++ b/crates/datalink/src/lib.rs @@ -0,0 +1,376 @@ +//! Virtual Data-Link Abstraction +//! +//! This crate provides a common abstraction for data communication links +//! that can be used by various vessel systems like AIS, GPS, Radar, etc. +//! +//! The abstraction allows systems to receive and transmit data through +//! different transport mechanisms (serial, network, simulation, etc.) +//! without being tightly coupled to the specific implementation. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::{Duration, SystemTime}; +use thiserror::Error; + +/// Errors that can occur in the data-link layer +#[derive(Error, Debug)] +pub enum DataLinkError { + #[error("Connection failed: {0}")] + ConnectionFailed(String), + #[error("Data parsing error: {0}")] + ParseError(String), + #[error("Timeout occurred")] + Timeout, + #[error("Invalid configuration: {0}")] + InvalidConfig(String), + #[error("Transport error: {0}")] + TransportError(String), +} + +/// Result type for data-link operations +pub type DataLinkResult = Result; + +/// Represents a generic data message that can be transmitted over the data-link +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataMessage { + /// Unique identifier for the message type + pub message_type: String, + /// Source identifier (e.g., MMSI for AIS, device ID for GPS) + pub source_id: String, + /// Timestamp when the message was created/received + pub timestamp: SystemTime, + /// Raw message payload + pub payload: Vec, + /// Parsed message data as key-value pairs + pub data: HashMap, + /// Signal strength or quality indicator (0-100) + pub signal_quality: Option, +} + +impl DataMessage { + /// Create a new data message + pub fn new(message_type: String, source_id: String, payload: Vec) -> Self { + Self { + message_type, + source_id, + timestamp: SystemTime::now(), + payload, + data: HashMap::new(), + signal_quality: None, + } + } + + /// Add parsed data to the message + pub fn with_data(mut self, key: String, value: String) -> Self { + self.data.insert(key, value); + self + } + + /// Set signal quality + pub fn with_signal_quality(mut self, quality: u8) -> Self { + self.signal_quality = Some(quality.min(100)); + self + } + + /// Get a data value by key + pub fn get_data(&self, key: &str) -> Option<&String> { + self.data.get(key) + } +} + +/// Configuration for a data-link connection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataLinkConfig { + /// Connection type (e.g., "serial", "tcp", "udp", "simulation") + pub connection_type: String, + /// Connection parameters (port, baud rate, IP address, etc.) + pub parameters: HashMap, + /// Timeout for operations + pub timeout: Duration, + /// Whether to auto-reconnect on failure + pub auto_reconnect: bool, +} + +impl DataLinkConfig { + /// Create a new configuration + pub fn new(connection_type: String) -> Self { + Self { + connection_type, + parameters: HashMap::new(), + timeout: Duration::from_secs(5), + auto_reconnect: true, + } + } + + /// Add a parameter to the configuration + pub fn with_parameter(mut self, key: String, value: String) -> Self { + self.parameters.insert(key, value); + self + } + + /// Set timeout + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } +} + +/// Status of a data-link connection +#[derive(Debug, Clone, PartialEq)] +pub enum DataLinkStatus { + /// Connection is active and receiving data + Connected, + /// Connection is being established + Connecting, + /// Connection is disconnected + Disconnected, + /// Connection has an error + Error(String), +} + +/// Trait for data-link receivers that can receive messages +pub trait DataLinkReceiver: Send + Sync { + /// Get the current status of the data-link + fn status(&self) -> DataLinkStatus; + + /// Receive the next available message, if any + fn receive_message(&mut self) -> DataLinkResult>; + + /// Receive all available messages + fn receive_all_messages(&mut self) -> DataLinkResult> { + let mut messages = Vec::new(); + while let Some(message) = self.receive_message()? { + messages.push(message); + } + Ok(messages) + } + + /// Connect to the data source + fn connect(&mut self, config: &DataLinkConfig) -> DataLinkResult<()>; + + /// Disconnect from the data source + fn disconnect(&mut self) -> DataLinkResult<()>; + + /// Check if the connection is active + fn is_connected(&self) -> bool { + matches!(self.status(), DataLinkStatus::Connected) + } +} + +/// Trait for data-link transmitters that can send messages +pub trait DataLinkTransmitter: Send + Sync { + /// Get the current status of the data-link + fn status(&self) -> DataLinkStatus; + + /// Send a message through the data-link + fn send_message(&mut self, message: &DataMessage) -> DataLinkResult<()>; + + /// Connect to the data destination + fn connect(&mut self, config: &DataLinkConfig) -> DataLinkResult<()>; + + /// Disconnect from the data destination + fn disconnect(&mut self) -> DataLinkResult<()>; + + /// Check if the connection is active + fn is_connected(&self) -> bool { + matches!(self.status(), DataLinkStatus::Connected) + } +} + +/// Combined trait for bidirectional data-links +pub trait DataLink: DataLinkReceiver + DataLinkTransmitter {} + +/// Automatic implementation for types that implement both receiver and transmitter +impl DataLink for T where T: DataLinkReceiver + DataLinkTransmitter {} + +/// A simulation data-link for testing and demonstration purposes +pub struct SimulationDataLink { + status: DataLinkStatus, + config: Option, + message_queue: Vec, +} + +impl SimulationDataLink { + /// Create a new simulation data-link + pub fn new() -> Self { + Self { + status: DataLinkStatus::Disconnected, + config: None, + message_queue: Vec::new(), + } + } + + /// Add a simulated message to the queue + pub fn add_simulated_message(&mut self, message: DataMessage) { + self.message_queue.push(message); + } + + /// Generate sample AIS messages for testing + pub fn generate_sample_ais_messages(&mut self) { + let messages = vec![ + DataMessage::new( + "AIS_POSITION".to_string(), + "987654321".to_string(), + b"!AIVDM,1,1,,A,15M8J7001G?UJH@E=4R0S>0@0<0M,0*7B".to_vec(), + ) + .with_data("vessel_name".to_string(), "M/Y SERENITY".to_string()) + .with_data("mmsi".to_string(), "987654321".to_string()) + .with_data("latitude".to_string(), "37.7749".to_string()) + .with_data("longitude".to_string(), "-122.4194".to_string()) + .with_data("speed".to_string(), "12.5".to_string()) + .with_data("course".to_string(), "180".to_string()) + .with_signal_quality(85), + + DataMessage::new( + "AIS_POSITION".to_string(), + "456789123".to_string(), + b"!AIVDM,1,1,,A,15M8J7001G?UJH@E=4R0S>0@0<0M,0*7B".to_vec(), + ) + .with_data("vessel_name".to_string(), "CARGO VESSEL ATLANTIS".to_string()) + .with_data("mmsi".to_string(), "456789123".to_string()) + .with_data("latitude".to_string(), "37.7849".to_string()) + .with_data("longitude".to_string(), "-122.4094".to_string()) + .with_data("speed".to_string(), "18.2".to_string()) + .with_data("course".to_string(), "090".to_string()) + .with_signal_quality(92), + + DataMessage::new( + "AIS_POSITION".to_string(), + "789123456".to_string(), + b"!AIVDM,1,1,,A,15M8J7001G?UJH@E=4R0S>0@0M,0*7B".to_vec(), + ) + .with_data("vessel_name".to_string(), "S/Y WIND DANCER".to_string()) + .with_data("mmsi".to_string(), "789123456".to_string()) + .with_data("latitude".to_string(), "37.7649".to_string()) + .with_data("longitude".to_string(), "-122.4294".to_string()) + .with_data("speed".to_string(), "6.8".to_string()) + .with_data("course".to_string(), "225".to_string()) + .with_signal_quality(78), + ]; + + for message in messages { + self.message_queue.push(message); + } + } +} + +impl Default for SimulationDataLink { + fn default() -> Self { + Self::new() + } +} + +impl DataLinkReceiver for SimulationDataLink { + fn status(&self) -> DataLinkStatus { + self.status.clone() + } + + fn receive_message(&mut self) -> DataLinkResult> { + if matches!(self.status, DataLinkStatus::Connected) && !self.message_queue.is_empty() { + Ok(Some(self.message_queue.remove(0))) + } else { + Ok(None) + } + } + + fn connect(&mut self, config: &DataLinkConfig) -> DataLinkResult<()> { + if config.connection_type == "simulation" { + self.config = Some(config.clone()); + self.status = DataLinkStatus::Connected; + // Generate some sample messages when connecting + self.generate_sample_ais_messages(); + Ok(()) + } else { + Err(DataLinkError::InvalidConfig( + "SimulationDataLink only supports 'simulation' connection type".to_string(), + )) + } + } + + fn disconnect(&mut self) -> DataLinkResult<()> { + self.status = DataLinkStatus::Disconnected; + self.config = None; + self.message_queue.clear(); + Ok(()) + } +} + +impl DataLinkTransmitter for SimulationDataLink { + fn status(&self) -> DataLinkStatus { + // Delegate to the receiver implementation + ::status(self) + } + + fn send_message(&mut self, _message: &DataMessage) -> DataLinkResult<()> { + if ::is_connected(self) { + // In simulation mode, we just acknowledge the send + Ok(()) + } else { + Err(DataLinkError::ConnectionFailed( + "Not connected".to_string(), + )) + } + } + + fn connect(&mut self, config: &DataLinkConfig) -> DataLinkResult<()> { + ::connect(self, config) + } + + fn disconnect(&mut self) -> DataLinkResult<()> { + ::disconnect(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_data_message_creation() { + let message = DataMessage::new( + "TEST".to_string(), + "123".to_string(), + b"test payload".to_vec(), + ) + .with_data("key1".to_string(), "value1".to_string()) + .with_signal_quality(75); + + assert_eq!(message.message_type, "TEST"); + assert_eq!(message.source_id, "123"); + assert_eq!(message.get_data("key1"), Some(&"value1".to_string())); + assert_eq!(message.signal_quality, Some(75)); + } + + #[test] + fn test_simulation_datalink() { + let mut datalink = SimulationDataLink::new(); + let config = DataLinkConfig::new("simulation".to_string()); + + assert_eq!(::status(&datalink), DataLinkStatus::Disconnected); + assert!(!::is_connected(&datalink)); + + ::connect(&mut datalink, &config).unwrap(); + assert_eq!(::status(&datalink), DataLinkStatus::Connected); + assert!(::is_connected(&datalink)); + + // Should have sample messages after connecting + let messages = ::receive_all_messages(&mut datalink).unwrap(); + assert!(!messages.is_empty()); + assert!(messages.iter().any(|m| m.message_type == "AIS_POSITION")); + + ::disconnect(&mut datalink).unwrap(); + assert_eq!(::status(&datalink), DataLinkStatus::Disconnected); + } + + #[test] + fn test_datalink_config() { + let config = DataLinkConfig::new("tcp".to_string()) + .with_parameter("host".to_string(), "localhost".to_string()) + .with_parameter("port".to_string(), "4001".to_string()) + .with_timeout(Duration::from_secs(10)); + + assert_eq!(config.connection_type, "tcp"); + assert_eq!(config.parameters.get("host"), Some(&"localhost".to_string())); + assert_eq!(config.timeout, Duration::from_secs(10)); + } +} diff --git a/crates/systems/Cargo.toml b/crates/systems/Cargo.toml index fff363c..556f5de 100644 --- a/crates/systems/Cargo.toml +++ b/crates/systems/Cargo.toml @@ -17,3 +17,4 @@ bevy = { workspace = true, features = [ ] } rand = { version = "0.8.3" } components = { path = "../components" } +datalink = { path = "../datalink" } diff --git a/crates/systems/src/ais/ais_system.rs b/crates/systems/src/ais/ais_system.rs index 382933c..0512469 100644 --- a/crates/systems/src/ais/ais_system.rs +++ b/crates/systems/src/ais/ais_system.rs @@ -1,20 +1,34 @@ use bevy::prelude::Time; use components::VesselData; use crate::{SystemInteraction, SystemStatus, VesselSystem}; +use datalink::{DataLink, DataLinkConfig, DataLinkReceiver, DataMessage, SimulationDataLink}; +use std::collections::HashMap; /// AIS (Automatic Identification System) implementation pub struct AisSystem { status: SystemStatus, own_mmsi: u32, receiving: bool, + datalink: SimulationDataLink, + vessel_data: HashMap, } impl AisSystem { pub fn new() -> Self { + let mut datalink = SimulationDataLink::new(); + let config = DataLinkConfig::new("simulation".to_string()); + + // Connect to the simulation datalink + if let Err(e) = datalink.connect(&config) { + eprintln!("Failed to connect AIS datalink: {}", e); + } + Self { status: SystemStatus::Active, own_mmsi: 123456789, receiving: true, + datalink, + vessel_data: HashMap::new(), } } } @@ -29,38 +43,72 @@ impl VesselSystem for AisSystem { } fn update(&mut self, _yacht_data: &VesselData, _time: &Time) { - // AIS system is relatively static, but we could simulate - // vessel movements or signal strength variations here + // Receive new AIS messages from the datalink + if self.receiving && self.datalink.is_connected() { + if let Ok(messages) = self.datalink.receive_all_messages() { + for message in messages { + if message.message_type == "AIS_POSITION" { + // Store vessel data by MMSI + if let Some(mmsi) = message.get_data("mmsi") { + self.vessel_data.insert(mmsi.clone(), message); + } + } + } + } + } } fn render_display(&self, _yacht_data: &VesselData) -> String { - format!( + let mut display = format!( "AIS - AUTOMATIC IDENTIFICATION SYSTEM\n\n\ Status: {}\n\ Own Ship MMSI: {}\n\ + Datalink: {}\n\ \n\ - NEARBY VESSELS:\n\ - \n\ - 🛥️ M/Y SERENITY\n\ - MMSI: 987654321\n\ - Distance: 2.1 NM @ 045°\n\ - Speed: 12.5 kts\n\ - Course: 180°\n\ - \n\ - 🚢 CARGO VESSEL ATLANTIS\n\ - MMSI: 456789123\n\ - Distance: 5.8 NM @ 270°\n\ - Speed: 18.2 kts\n\ - Course: 090°\n\ - \n\ - ⛵ S/Y WIND DANCER\n\ - MMSI: 789123456\n\ - Distance: 1.3 NM @ 135°\n\ - Speed: 6.8 kts\n\ - Course: 225°", + NEARBY VESSELS:\n", if self.receiving { "RECEIVING" } else { "STANDBY" }, - self.own_mmsi - ) + self.own_mmsi, + if self.datalink.is_connected() { "CONNECTED" } else { "DISCONNECTED" } + ); + + if self.vessel_data.is_empty() { + display.push_str("\nNo vessels detected"); + } else { + for (mmsi, message) in &self.vessel_data { + let vessel_name = message.get_data("vessel_name").unwrap_or(mmsi); + let speed = message.get_data("speed").map(|s| s.as_str()).unwrap_or("N/A"); + let course = message.get_data("course").map(|s| s.as_str()).unwrap_or("N/A"); + let lat = message.get_data("latitude").map(|s| s.as_str()).unwrap_or("N/A"); + let lon = message.get_data("longitude").map(|s| s.as_str()).unwrap_or("N/A"); + + // Determine vessel icon based on name + let icon = if vessel_name.contains("M/Y") || vessel_name.contains("YACHT") { + "🛥️" + } else if vessel_name.contains("CARGO") || vessel_name.contains("SHIP") { + "🚢" + } else if vessel_name.contains("S/Y") || vessel_name.contains("SAIL") { + "⛵" + } else { + "🚤" + }; + + display.push_str(&format!( + "\n{} {}\n\ + MMSI: {}\n\ + Position: {}°N, {}°W\n\ + Speed: {} kts\n\ + Course: {}°\n", + icon, vessel_name, mmsi, lat, lon, speed, course + )); + + if let Some(quality) = message.signal_quality { + display.push_str(&format!("Signal: {}%\n", quality)); + } + display.push('\n'); + } + } + + display } fn handle_interaction(&mut self, interaction: SystemInteraction) -> bool { @@ -104,4 +152,4 @@ impl VesselSystem for AisSystem { fn status(&self) -> SystemStatus { self.status.clone() } -} \ No newline at end of file +}