mirror of
https://github.com/seemueller-io/yachtpit.git
synced 2025-09-08 22:46:45 +00:00

* Introduce core modules: device management, bus communication, and discovery protocol. Adds system device interface, virtual hardware bus, and device discovery logic. Includes tests for all components. * improve map - Fix typos in variable and function names (`vessle` to `vessel`). - Add `update_vessel_data_with_gps` function to enable GPS integration for vessel data updates. - Integrate real GPS data into vessel systems and UI components (speed, heading, etc.). - Initialize speed gauge display at 0 kts. - Include `useEffect` in `MapNext` to log and potentially handle `vesselPosition` changes. **Add compass heading update system using GPS heading data.** - Remove `UserLocationMarker` component and related code from `MapNext.tsx` - Simplify logic for layer selection and navigation within `App.tsx` - Replace map style 'Bathymetry' with 'OSM' in layer options improve map * update image --------- Co-authored-by: geoffsee <>
534 lines
17 KiB
Rust
534 lines
17 KiB
Rust
//! GPS Device Implementation
|
|
//!
|
|
//! This module provides a GPS device that integrates with the hardware abstraction layer.
|
|
//! It uses NMEA sentence parsing to extract location data from GPS hardware and broadcasts
|
|
//! location updates via the hardware bus.
|
|
|
|
use crate::{
|
|
BusAddress, BusMessage, DeviceCapability, DeviceConfig, DeviceInfo, DeviceStatus,
|
|
HardwareError, Result, SystemDevice,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::io::{BufRead, BufReader, ErrorKind};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, SystemTime};
|
|
use tokio::sync::Mutex;
|
|
use tracing::{debug, error, info, warn};
|
|
use uuid::Uuid;
|
|
|
|
/// GPS location data structure
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct LocationData {
|
|
pub latitude: Option<f64>,
|
|
pub longitude: Option<f64>,
|
|
pub altitude: Option<f64>,
|
|
pub speed: Option<f64>,
|
|
pub timestamp: Option<String>,
|
|
pub fix_quality: Option<u8>,
|
|
pub satellites: Option<u8>,
|
|
}
|
|
|
|
impl Default for LocationData {
|
|
fn default() -> Self {
|
|
LocationData {
|
|
latitude: None,
|
|
longitude: None,
|
|
altitude: None,
|
|
speed: None,
|
|
timestamp: None,
|
|
fix_quality: None,
|
|
satellites: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GNSS parser for NMEA sentences
|
|
pub struct GnssParser;
|
|
|
|
impl GnssParser {
|
|
pub fn new() -> Self {
|
|
GnssParser
|
|
}
|
|
|
|
pub fn parse_sentence(&self, sentence: &str) -> Option<LocationData> {
|
|
if sentence.is_empty() || !sentence.starts_with('$') {
|
|
return None;
|
|
}
|
|
|
|
let parts: Vec<&str> = sentence.split(',').collect();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let sentence_type = parts[0];
|
|
|
|
match sentence_type {
|
|
"$GPGGA" | "$GNGGA" => self.parse_gpgga(&parts),
|
|
"$GPRMC" | "$GNRMC" => self.parse_gprmc(&parts),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn parse_gpgga(&self, parts: &[&str]) -> Option<LocationData> {
|
|
if parts.len() < 15 {
|
|
return None;
|
|
}
|
|
|
|
let mut location = LocationData::default();
|
|
|
|
// Parse timestamp (field 1)
|
|
if !parts[1].is_empty() {
|
|
location.timestamp = Some(parts[1].to_string());
|
|
}
|
|
|
|
// Parse latitude (fields 2 and 3)
|
|
if !parts[2].is_empty() && !parts[3].is_empty() {
|
|
if let Ok(lat_raw) = parts[2].parse::<f64>() {
|
|
let degrees = (lat_raw / 100.0).floor();
|
|
let minutes = lat_raw - (degrees * 100.0);
|
|
let mut latitude = degrees + (minutes / 60.0);
|
|
|
|
if parts[3] == "S" {
|
|
latitude = -latitude;
|
|
}
|
|
location.latitude = Some(latitude);
|
|
}
|
|
}
|
|
|
|
// Parse longitude (fields 4 and 5)
|
|
if !parts[4].is_empty() && !parts[5].is_empty() {
|
|
if let Ok(lon_raw) = parts[4].parse::<f64>() {
|
|
let degrees = (lon_raw / 100.0).floor();
|
|
let minutes = lon_raw - (degrees * 100.0);
|
|
let mut longitude = degrees + (minutes / 60.0);
|
|
|
|
if parts[5] == "W" {
|
|
longitude = -longitude;
|
|
}
|
|
location.longitude = Some(longitude);
|
|
}
|
|
}
|
|
|
|
// Parse fix quality (field 6)
|
|
if !parts[6].is_empty() {
|
|
if let Ok(quality) = parts[6].parse::<u8>() {
|
|
location.fix_quality = Some(quality);
|
|
}
|
|
}
|
|
|
|
// Parse number of satellites (field 7)
|
|
if !parts[7].is_empty() {
|
|
if let Ok(sats) = parts[7].parse::<u8>() {
|
|
location.satellites = Some(sats);
|
|
}
|
|
}
|
|
|
|
// Parse altitude (field 9)
|
|
if !parts[9].is_empty() {
|
|
if let Ok(alt) = parts[9].parse::<f64>() {
|
|
location.altitude = Some(alt);
|
|
}
|
|
}
|
|
|
|
Some(location)
|
|
}
|
|
|
|
fn parse_gprmc(&self, parts: &[&str]) -> Option<LocationData> {
|
|
if parts.len() < 12 {
|
|
return None;
|
|
}
|
|
|
|
let mut location = LocationData::default();
|
|
|
|
// Parse timestamp (field 1)
|
|
if !parts[1].is_empty() {
|
|
location.timestamp = Some(parts[1].to_string());
|
|
}
|
|
|
|
// Check if data is valid (field 2)
|
|
if parts[2] != "A" {
|
|
return None; // Invalid data
|
|
}
|
|
|
|
// Parse latitude (fields 3 and 4)
|
|
if !parts[3].is_empty() && !parts[4].is_empty() {
|
|
if let Ok(lat_raw) = parts[3].parse::<f64>() {
|
|
let degrees = (lat_raw / 100.0).floor();
|
|
let minutes = lat_raw - (degrees * 100.0);
|
|
let mut latitude = degrees + (minutes / 60.0);
|
|
|
|
if parts[4] == "S" {
|
|
latitude = -latitude;
|
|
}
|
|
location.latitude = Some(latitude);
|
|
}
|
|
}
|
|
|
|
// Parse longitude (fields 5 and 6)
|
|
if !parts[5].is_empty() && !parts[6].is_empty() {
|
|
if let Ok(lon_raw) = parts[5].parse::<f64>() {
|
|
let degrees = (lon_raw / 100.0).floor();
|
|
let minutes = lon_raw - (degrees * 100.0);
|
|
let mut longitude = degrees + (minutes / 60.0);
|
|
|
|
if parts[6] == "W" {
|
|
longitude = -longitude;
|
|
}
|
|
location.longitude = Some(longitude);
|
|
}
|
|
}
|
|
|
|
// Parse speed (field 7) - in knots
|
|
if !parts[7].is_empty() {
|
|
if let Ok(speed_knots) = parts[7].parse::<f64>() {
|
|
location.speed = Some(speed_knots);
|
|
}
|
|
}
|
|
|
|
Some(location)
|
|
}
|
|
}
|
|
|
|
/// GPS Device configuration
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct GpsDeviceConfig {
|
|
pub device_paths: Vec<String>,
|
|
pub baud_rate: u32,
|
|
pub timeout_ms: u64,
|
|
pub auto_reconnect: bool,
|
|
pub broadcast_interval_ms: u64,
|
|
}
|
|
|
|
impl Default for GpsDeviceConfig {
|
|
fn default() -> Self {
|
|
GpsDeviceConfig {
|
|
device_paths: vec![
|
|
"/dev/tty.usbmodem2101".to_string(),
|
|
"/dev/cu.usbmodem2101".to_string(),
|
|
"/dev/ttyUSB0".to_string(),
|
|
"/dev/ttyACM0".to_string(),
|
|
],
|
|
baud_rate: 9600,
|
|
timeout_ms: 1000,
|
|
auto_reconnect: true,
|
|
broadcast_interval_ms: 1000,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GPS Device implementation
|
|
pub struct GpsDevice {
|
|
device_info: DeviceInfo,
|
|
gps_config: GpsDeviceConfig,
|
|
parser: GnssParser,
|
|
last_location: Option<LocationData>,
|
|
serial_port: Option<Arc<Mutex<Box<dyn serialport::SerialPort>>>>,
|
|
running: bool,
|
|
}
|
|
|
|
impl GpsDevice {
|
|
pub fn new(gps_config: GpsDeviceConfig) -> Self {
|
|
let address = BusAddress::new("GPS_DEVICE");
|
|
|
|
// Create device config for the hardware abstraction layer
|
|
let device_config = DeviceConfig {
|
|
name: "GPS Device".to_string(),
|
|
capabilities: vec![DeviceCapability::Gps, DeviceCapability::Navigation],
|
|
update_interval_ms: gps_config.broadcast_interval_ms,
|
|
max_queue_size: 100,
|
|
custom_config: HashMap::new(),
|
|
};
|
|
|
|
let device_info = DeviceInfo {
|
|
address,
|
|
config: device_config,
|
|
status: DeviceStatus::Offline,
|
|
last_seen: SystemTime::now(),
|
|
version: "1.0.0".to_string(),
|
|
manufacturer: "YachtPit".to_string(),
|
|
};
|
|
|
|
GpsDevice {
|
|
device_info,
|
|
gps_config,
|
|
parser: GnssParser::new(),
|
|
last_location: None,
|
|
serial_port: None,
|
|
running: false,
|
|
}
|
|
}
|
|
|
|
pub fn with_address(mut self, address: BusAddress) -> Self {
|
|
self.device_info.address = address;
|
|
self
|
|
}
|
|
|
|
async fn try_connect_serial(&mut self) -> Result<()> {
|
|
for device_path in &self.gps_config.device_paths {
|
|
debug!("Attempting to connect to GPS device at: {}", device_path);
|
|
|
|
match serialport::new(device_path, self.gps_config.baud_rate)
|
|
.timeout(Duration::from_millis(self.gps_config.timeout_ms))
|
|
.open()
|
|
{
|
|
Ok(port) => {
|
|
info!("Successfully connected to GPS device at {}", device_path);
|
|
self.serial_port = Some(Arc::new(Mutex::new(port)));
|
|
return Ok(());
|
|
}
|
|
Err(e) => {
|
|
debug!("Failed to connect to {}: {}", device_path, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(HardwareError::generic(
|
|
"Could not connect to any GPS device"
|
|
))
|
|
}
|
|
|
|
async fn read_and_parse_gps_data(&mut self) -> Result<Vec<BusMessage>> {
|
|
let mut messages = Vec::new();
|
|
|
|
if let Some(ref serial_port) = self.serial_port {
|
|
let mut port_guard = serial_port.lock().await;
|
|
let mut reader = BufReader::new(port_guard.as_mut());
|
|
let mut line = String::new();
|
|
|
|
// Try to read a line with timeout handling
|
|
match reader.read_line(&mut line) {
|
|
Ok(0) => {
|
|
// EOF - connection lost
|
|
warn!("GPS device connection lost (EOF)");
|
|
drop(port_guard); // Release the lock before modifying self
|
|
self.serial_port = None;
|
|
self.device_info.status = DeviceStatus::Offline;
|
|
}
|
|
Ok(_) => {
|
|
let sentence = line.trim();
|
|
if !sentence.is_empty() {
|
|
debug!("Received GPS sentence: {}", sentence);
|
|
|
|
if let Some(location) = self.parser.parse_sentence(sentence) {
|
|
info!("Parsed GPS location: {:?}", location);
|
|
self.last_location = Some(location.clone());
|
|
|
|
// Create broadcast message with location data
|
|
if let Ok(payload) = serde_json::to_vec(&location) {
|
|
let message = BusMessage::Broadcast {
|
|
from: self.device_info.address.clone(),
|
|
payload,
|
|
message_id: Uuid::new_v4(),
|
|
};
|
|
messages.push(message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
match e.kind() {
|
|
ErrorKind::TimedOut => {
|
|
// Timeout is normal, just continue
|
|
debug!("GPS read timeout - continuing");
|
|
}
|
|
ErrorKind::Interrupted => {
|
|
// Interrupted system call - continue
|
|
debug!("GPS read interrupted - continuing");
|
|
}
|
|
_ => {
|
|
error!("Error reading from GPS device: {}", e);
|
|
drop(port_guard); // Release the lock before modifying self
|
|
self.serial_port = None;
|
|
self.device_info.status = DeviceStatus::Error {
|
|
message: format!("GPS read error: {}", e),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(messages)
|
|
}
|
|
|
|
pub fn get_last_location(&self) -> Option<&LocationData> {
|
|
self.last_location.as_ref()
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl SystemDevice for GpsDevice {
|
|
async fn initialize(&mut self) -> Result<()> {
|
|
info!("Initializing GPS device");
|
|
self.device_info.status = DeviceStatus::Initializing;
|
|
self.device_info.last_seen = SystemTime::now();
|
|
|
|
// Try to connect to GPS hardware
|
|
match self.try_connect_serial().await {
|
|
Ok(()) => {
|
|
self.device_info.status = DeviceStatus::Online;
|
|
info!("GPS device initialized successfully");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
warn!("GPS device initialization failed: {}", e);
|
|
self.device_info.status = DeviceStatus::Error {
|
|
message: format!("Initialization failed: {}", e),
|
|
};
|
|
// Return error when hardware GPS is not available
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn start(&mut self) -> Result<()> {
|
|
info!("Starting GPS device");
|
|
self.running = true;
|
|
self.device_info.last_seen = SystemTime::now();
|
|
|
|
if self.serial_port.is_some() {
|
|
self.device_info.status = DeviceStatus::Online;
|
|
} else {
|
|
// Try to reconnect if auto-reconnect is enabled
|
|
if self.gps_config.auto_reconnect {
|
|
if let Ok(()) = self.try_connect_serial().await {
|
|
self.device_info.status = DeviceStatus::Online;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn stop(&mut self) -> Result<()> {
|
|
info!("Stopping GPS device");
|
|
self.running = false;
|
|
self.serial_port = None;
|
|
self.device_info.status = DeviceStatus::Offline;
|
|
self.device_info.last_seen = SystemTime::now();
|
|
Ok(())
|
|
}
|
|
|
|
fn get_info(&self) -> DeviceInfo {
|
|
self.device_info.clone()
|
|
}
|
|
|
|
fn get_status(&self) -> DeviceStatus {
|
|
self.device_info.status.clone()
|
|
}
|
|
|
|
async fn handle_message(&mut self, message: BusMessage) -> Result<Option<BusMessage>> {
|
|
debug!("GPS device received message: {:?}", message);
|
|
self.device_info.last_seen = SystemTime::now();
|
|
|
|
match message {
|
|
BusMessage::Control { command, .. } => {
|
|
match command {
|
|
crate::bus::ControlCommand::Ping { target } => {
|
|
if target == self.device_info.address {
|
|
return Ok(Some(BusMessage::Control {
|
|
from: self.device_info.address.clone(),
|
|
command: crate::bus::ControlCommand::Pong {
|
|
from: self.device_info.address.clone(),
|
|
},
|
|
message_id: Uuid::new_v4(),
|
|
}));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
async fn process(&mut self) -> Result<Vec<BusMessage>> {
|
|
if !self.running {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
self.device_info.last_seen = SystemTime::now();
|
|
|
|
// Try to read GPS data if connected
|
|
if self.serial_port.is_some() {
|
|
self.read_and_parse_gps_data().await
|
|
} else if self.gps_config.auto_reconnect {
|
|
// Try to reconnect
|
|
if let Ok(()) = self.try_connect_serial().await {
|
|
info!("GPS device reconnected successfully");
|
|
self.device_info.status = DeviceStatus::Online;
|
|
}
|
|
Ok(Vec::new())
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
fn get_capabilities(&self) -> Vec<DeviceCapability> {
|
|
self.device_info.config.capabilities.clone()
|
|
}
|
|
|
|
async fn update_config(&mut self, _config: DeviceConfig) -> Result<()> {
|
|
// For now, we don't support dynamic config updates
|
|
// In a real implementation, you might want to parse the config
|
|
// and update the GPS-specific settings
|
|
warn!("GPS device config update not implemented");
|
|
self.device_info.last_seen = SystemTime::now();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_gnss_parser_creation() {
|
|
let parser = GnssParser::new();
|
|
// Parser should be created successfully
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_gpgga_sentence() {
|
|
let parser = GnssParser::new();
|
|
let sentence = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47";
|
|
|
|
let result = parser.parse_sentence(sentence);
|
|
assert!(result.is_some());
|
|
|
|
let location = result.unwrap();
|
|
assert!(location.latitude.is_some());
|
|
assert!(location.longitude.is_some());
|
|
assert!(location.altitude.is_some());
|
|
assert!((location.latitude.unwrap() - 48.1173).abs() < 0.001);
|
|
assert!((location.longitude.unwrap() - 11.5167).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gps_device_creation() {
|
|
let config = GpsDeviceConfig::default();
|
|
let device = GpsDevice::new(config);
|
|
assert_eq!(device.get_status(), DeviceStatus::Offline);
|
|
assert!(device.get_capabilities().contains(&DeviceCapability::Gps));
|
|
}
|
|
|
|
#[test]
|
|
fn test_location_data_serialization() {
|
|
let location = LocationData {
|
|
latitude: Some(48.1173),
|
|
longitude: Some(11.5167),
|
|
altitude: Some(545.4),
|
|
speed: Some(22.4),
|
|
timestamp: Some("123519".to_string()),
|
|
fix_quality: Some(1),
|
|
satellites: Some(8),
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&location).unwrap();
|
|
let deserialized: LocationData = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(location, deserialized);
|
|
}
|
|
} |