diff --git a/.aiignore b/.aiignore
deleted file mode 100644
index de7901f..0000000
--- a/.aiignore
+++ /dev/null
@@ -1,14 +0,0 @@
-# An .aiignore file follows the same syntax as a .gitignore file.
-# .gitignore documentation: https://git-scm.com/docs/gitignore
-# Junie will ask for explicit approval before view or edit the file or file within a directory listed in .aiignore.
-# Only files contents is protected, Junie is still allowed to view file names even if they are listed in .aiignore.
-# Be aware that the files you included in .aiignore can still be accessed by Junie in two cases:
-# - If Brave Mode is turned on.
-# - If a command has been added to the Allowlist — Junie will not ask for confirmation, even if it accesses - files and folders listed in .aiignore.
-target
-**/**/dist
-.idea
-.github
-Cargo.lock
-LICENSE
-yachtpit.png
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3dbe847..02ce09e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/crates/yachtpit/assets/ui/assets/
/crates/yachtpit/assets/ui/packages/base-map/dist/
+/crates/base-map/map/src/map-upgrade/
diff --git a/Cargo.lock b/Cargo.lock
index 830c279..7b77d43 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4274,6 +4274,26 @@ dependencies = [
"redox_syscall 0.5.13",
]
+[[package]]
+name = "libudev"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
+dependencies = [
+ "libc",
+ "libudev-sys",
+]
+
+[[package]]
+name = "libudev-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -6573,6 +6593,7 @@ dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"io-kit-sys",
+ "libudev",
"mach2",
"nix 0.26.4",
"scopeguard",
@@ -9088,6 +9109,7 @@ checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
name = "yachtpit"
version = "0.1.0"
dependencies = [
+ "base-map",
"bevy",
"bevy_asset_loader",
"bevy_flurx",
@@ -9102,6 +9124,7 @@ dependencies = [
"rand 0.8.5",
"serde",
"serde_json",
+ "serialport",
"systems",
"tokio",
"wasm-bindgen",
diff --git a/README.md b/README.md
index f19612e..5254b9a 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
> Warning: Experimental, incomplete, and unfunded.
-
+
diff --git a/crates/base-map/map/src/App.tsx b/crates/base-map/map/src/App.tsx
index c0887f8..6ef2801 100644
--- a/crates/base-map/map/src/App.tsx
+++ b/crates/base-map/map/src/App.tsx
@@ -1,16 +1,17 @@
-// import Map from 'react-map-gl/mapbox';
-// import {Source, Layer} from 'react-map-gl/maplibre';
import 'mapbox-gl/dist/mapbox-gl.css';
import {Box, Button, HStack, Input} from '@chakra-ui/react';
import {useCallback, useEffect, useState} from "react";
-import MapNext from "@/MapNext.tsx";
-// import type {FeatureCollection} from 'geojson';
-// import type {CircleLayerSpecification} from "mapbox-gl";
+import MapNext, {type Geolocation} from "@/MapNext.tsx";
// public key
const key =
'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
+const layers = [
+ { name: 'OSM', value: 'mapbox://styles/mapbox/dark-v11' },
+ { name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
+];
+
// const vesselLayerStyle: CircleLayerSpecification = {
@@ -39,6 +40,31 @@ interface VesselStatus {
speed: number;
}
+export type Layer = { name: string; value: string };
+export type Layers = Layer[];
+
+class MyGeolocation implements Geolocation {
+ constructor({clearWatch, getCurrentPosition, watchPosition}: {
+ clearWatch: (watchId: number) => void;
+ getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => void;
+ watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => number;
+ }) {
+ this.clearWatch = clearWatch;
+ this.watchPosition = watchPosition;
+ this.getCurrentPosition = getCurrentPosition;
+ }
+ clearWatch(_watchId: number): void {
+ throw new Error('Method not implemented.');
+ }
+ getCurrentPosition(_successCallback: PositionCallback, _errorCallback?: PositionErrorCallback | null, _options?: PositionOptions): void {
+ throw new Error('Method not implemented.');
+ }
+ watchPosition(_successCallback: PositionCallback, _errorCallback?: PositionErrorCallback | null, _options?: PositionOptions): number {
+ throw new Error('Method not implemented.');
+ }
+
+}
+
// interface MapViewParams {
// latitude: number;
// longitude: number;
@@ -50,9 +76,58 @@ interface VesselStatus {
// token: string | null;
// }
+function LayerSelector(props: { onClick: (e: any) => Promise }) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+ setIsOpen(!isOpen)}>
+ Layer
+
+
+ {isOpen && (
+
+ {layers.map(layer => (
+ {
+ setIsOpen(false);
+ await props.onClick(e);
+ }}
+ >
+ {layer.name}
+
+ ))}
+
+ )}
+
+ );
+}
+
function App() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
+ const [selectedLayer, setSelectedLayer] = useState(layers[0]);
+ const [searchInput, setSearchInput] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [mapView, setMapView] = useState({
+ longitude: -122.4,
+ latitude: 37.8,
+ zoom: 14
+ });
// Map state that can be updated from Rust
// const [mapView, setMapView] = useState({
@@ -62,7 +137,7 @@ function App() {
// });
// Vessel position state
- // const [vesselPosition, setVesselPosition] = useState(null);
+ const [vesselPosition, setVesselPosition] = useState(null);
// Create vessel geojson data
// const vesselGeojson: FeatureCollection = {
@@ -85,20 +160,51 @@ function App() {
// Button click handlers
- const handleNavigationClick = useCallback(async () => {
- if (typeof window !== 'undefined' && (window as any).__FLURX__) {
- try {
- await (window as any).__FLURX__.invoke("navigation_clicked");
- console.log('Navigation clicked');
- } catch (error) {
- console.error('Failed to invoke navigation_clicked:', error);
- }
- }
+ // const handleNavigationClick = useCallback(async () => {
+ // if (typeof window !== 'undefined' && (window as any).__FLURX__) {
+ // try {
+ // await (window as any).__FLURX__.invoke("navigation_clicked");
+ // console.log('Navigation clicked');
+ // } catch (error) {
+ // console.error('Failed to invoke navigation_clicked:', error);
+ // }
+ // }
+ // }, []);
+
+
+ const selectSearchResult = useCallback(async (searchResult: { lat: string, lon: string }) => {
+ // Navigate to the selected location with zoom
+ console.log(`Navigating to: ${searchResult.lat}, ${searchResult.lon}`);
+ setMapView({
+ longitude: parseFloat(searchResult.lon),
+ latitude: parseFloat(searchResult.lat),
+ zoom: 15
+ });
}, []);
-
const handleSearchClick = useCallback(async () => {
- setIsSearchOpen(!isSearchOpen);
+ if (isSearchOpen && searchInput.length > 1) {
+ try {
+ console.log(`Trying to geocode: ${searchInput}`);
+ const geocode = await fetch('https://geocode.geoffsee.com', {
+ method: 'POST',
+ mode: 'cors',
+ body: JSON.stringify({
+ location: searchInput,
+ }),
+ });
+ const coordinates = await geocode.json();
+ const { lat, lon } = coordinates;
+ console.log(`Got geocode coordinates: ${lat}, ${lon}`);
+ setSearchResults([{ lat, lon }]);
+ } catch (e) {
+ console.error('Geocoding failed:', e);
+ // Continue without results
+ }
+ } else {
+ setIsSearchOpen(!isSearchOpen);
+ }
+
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
try {
await (window as any).__FLURX__.invoke("search_clicked");
@@ -107,6 +213,14 @@ function App() {
console.error('Failed to invoke search_clicked:', error);
}
}
+ }, [isSearchOpen, searchInput]);
+
+ const handleLayerChange = useCallback(async (e: any) => {
+ const newLayer = layers.find(layer => layer.value === e.target.id);
+ if (newLayer) {
+ setSelectedLayer(newLayer);
+ console.log('Layer changed to:', newLayer.name);
+ }
}, []);
// const handleMapViewChange = useCallback(async (evt: any) => {
@@ -135,7 +249,7 @@ function App() {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
console.log('Vessel status:', vesselStatus);
- // setVesselPosition(vesselStatus);
+ setVesselPosition(vesselStatus);
} catch (error) {
console.error('Failed to get vessel status:', error);
}
@@ -175,6 +289,28 @@ function App() {
return (
/* Full-screen wrapper — fills the viewport and becomes the positioning context */
+ {/* GPS Feed Display — absolutely positioned at bottom-left */}
+ {vesselPosition && (
+
+ GPS Feed
+ Lat: {vesselPosition.latitude.toFixed(6)}°
+ Lon: {vesselPosition.longitude.toFixed(6)}°
+ Heading: {vesselPosition.heading.toFixed(1)}°
+ Speed: {vesselPosition.speed.toFixed(1)} kts
+
+ )}
{/* Button bar — absolutely positioned inside the wrapper */}
setSearchInput(e.target.value)}
+ color="white"
+ bg="rgba(0, 0, 0, 0.8)"
+ border="none"
+ borderRadius="0"
+ _focus={{
+ outline: 'none',
+ }}
_placeholder={{
color: "#d1cfcf"
}}
/>
+ {searchResults.length > 0 && (
+
+ {searchResults.map((result, index) => (
+ {
+ console.log(`Selecting result ${result.lat}, ${result.lon}`);
+ await selectSearchResult(result);
+ setSearchResults([]);
+ setIsSearchOpen(false);
+ }}
+ >
+ {`${result.lat}, ${result.lon}`}
+
+ ))}
+
+ )}
}
-
- Layer
-
+
-
+ {
+ if (typeof window !== 'undefined' && (window as any).geolocationWatches) {
+ const interval = (window as any).geolocationWatches.get(watchId);
+ if (interval) {
+ clearInterval(interval);
+ (window as any).geolocationWatches.delete(watchId);
+ }
+ }
+ },
+ watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => {
+ if (typeof window === 'undefined') return 0;
+
+ // Initialize watches map if it doesn't exist
+ if (!(window as any).geolocationWatches) {
+ (window as any).geolocationWatches = new Map();
+ }
+ if (!(window as any).geolocationWatchId) {
+ (window as any).geolocationWatchId = 0;
+ }
+
+ const watchId = ++(window as any).geolocationWatchId;
+
+ const pollPosition = async () => {
+ if ((window as any).__FLURX__) {
+ try {
+ const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
+ const position: GeolocationPosition = {
+ coords: {
+ latitude: vesselStatus.latitude,
+ longitude: vesselStatus.longitude,
+ altitude: null,
+ accuracy: 10, // Assume 10m accuracy
+ altitudeAccuracy: null,
+ heading: vesselStatus.heading,
+ speed: vesselStatus.speed,
+ toJSON: () => ({
+ latitude: vesselStatus.latitude,
+ longitude: vesselStatus.longitude,
+ altitude: null,
+ accuracy: 10,
+ altitudeAccuracy: null,
+ heading: vesselStatus.heading,
+ speed: vesselStatus.speed
+ })
+ },
+ timestamp: Date.now(),
+ toJSON: () => ({
+ coords: {
+ latitude: vesselStatus.latitude,
+ longitude: vesselStatus.longitude,
+ altitude: null,
+ accuracy: 10,
+ altitudeAccuracy: null,
+ heading: vesselStatus.heading,
+ speed: vesselStatus.speed
+ },
+ timestamp: Date.now()
+ })
+ };
+ successCallback(position);
+ } catch (error) {
+ if (errorCallback) {
+ const positionError: GeolocationPositionError = {
+ code: 2, // POSITION_UNAVAILABLE
+ message: 'Failed to get vessel status: ' + error,
+ PERMISSION_DENIED: 1,
+ POSITION_UNAVAILABLE: 2,
+ TIMEOUT: 3
+ };
+ errorCallback(positionError);
+ }
+ }
+ }
+ };
+
+ // Poll immediately and then at intervals
+ pollPosition();
+ const interval = setInterval(pollPosition, options?.timeout || 5000);
+ (window as any).geolocationWatches.set(watchId, interval);
+
+ return watchId;
+ },
+ getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, _options?: PositionOptions) => {
+ if (typeof window !== 'undefined' && (window as any).__FLURX__) {
+ (async () => {
+ try {
+ const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
+ const position: GeolocationPosition = {
+ coords: {
+ latitude: vesselStatus.latitude,
+ longitude: vesselStatus.longitude,
+ altitude: null,
+ accuracy: 10, // Assume 10m accuracy
+ altitudeAccuracy: null,
+ heading: vesselStatus.heading,
+ speed: vesselStatus.speed,
+ toJSON: () => ({
+ latitude: vesselStatus.latitude,
+ longitude: vesselStatus.longitude,
+ altitude: null,
+ accuracy: 10,
+ altitudeAccuracy: null,
+ heading: vesselStatus.heading,
+ speed: vesselStatus.speed
+ })
+ },
+ timestamp: Date.now(),
+ toJSON: () => ({
+ coords: {
+ latitude: vesselStatus.latitude,
+ longitude: vesselStatus.longitude,
+ altitude: null,
+ accuracy: 10,
+ altitudeAccuracy: null,
+ heading: vesselStatus.heading,
+ speed: vesselStatus.speed
+ },
+ timestamp: Date.now()
+ })
+ };
+ successCallback(position);
+ } catch (error) {
+ if (errorCallback) {
+ const positionError: GeolocationPositionError = {
+ code: 2, // POSITION_UNAVAILABLE
+ message: 'Failed to get vessel status: ' + error,
+ PERMISSION_DENIED: 1,
+ POSITION_UNAVAILABLE: 2,
+ TIMEOUT: 3
+ };
+ errorCallback(positionError);
+ }
+ }
+ })();
+ } else if (errorCallback) {
+ const positionError: GeolocationPositionError = {
+ code: 2, // POSITION_UNAVAILABLE
+ message: '__FLURX__ not available',
+ PERMISSION_DENIED: 1,
+ POSITION_UNAVAILABLE: 2,
+ TIMEOUT: 3
+ };
+ errorCallback(positionError);
+ }
+ },
+ })}/>
{/* {
+ console.log("props.vesselPosition", props?.vesselPosition);
+ // setLocationLock(props.vesselPosition)
+ }, [props.vesselPosition]);
+
return (
-
+
@@ -107,6 +125,9 @@ export default function MapNext(props: any = {mapboxPublicKey: ""} as any) {
)}
+
+
+
diff --git a/crates/base-map/map/src/user-location-marker.tsx b/crates/base-map/map/src/user-location-marker.tsx
new file mode 100644
index 0000000..1717592
--- /dev/null
+++ b/crates/base-map/map/src/user-location-marker.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+
+/**
+ * UserLocationMarker
+ * • size – overall diameter in px (default 24)
+ * • color – dot / ring colour (default #1E90FF ⟵ system‑blue)
+ * • pulse – adds a subtle accuracy‑halo animation when true
+ */
+function UserLocationMarker({
+ size = 24,
+ color = '#1E90FF',
+ pulse = false
+ }) {
+ // stroke width scales with size so the ring stays proportionate
+ const strokeWidth = size * 0.083; // ≈ 2px when size = 24
+
+ // keyframes are injected once per page‑load if pulse is ever enabled
+ React.useEffect(() => {
+ if (!pulse || document.getElementById('ulm‑pulse‑kf')) return;
+ const styleTag = document.createElement('style');
+ styleTag.id = 'ulm‑pulse‑kf';
+ styleTag.textContent = `
+ @keyframes ulm‑pulse {
+ 0% { r: 0; opacity: .6; }
+ 70% { r: 12px; opacity: 0; }
+ 100% { r: 12px; opacity: 0; }
+ }`;
+ document.head.appendChild(styleTag);
+ }, [pulse]);
+
+ return (
+
+ {/* accuracy halo (animated when pulse=true) */}
+ {pulse && (
+
+ )}
+
+ {/* outer ring */}
+
+
+ {/* inner dot */}
+
+
+ );
+}
+
+export default React.memo(UserLocationMarker);
diff --git a/crates/components/src/instrument_cluster.rs b/crates/components/src/instrument_cluster.rs
index 16fa54b..f392d18 100644
--- a/crates/components/src/instrument_cluster.rs
+++ b/crates/components/src/instrument_cluster.rs
@@ -39,7 +39,7 @@ pub fn setup_instrument_cluster(mut commands: Commands) {
))
.with_children(|gauge| {
gauge.spawn(create_text("SPEED", FONT_SIZE_SMALL, TEXT_COLOR_PRIMARY));
- gauge.spawn(create_text("12.5", FONT_SIZE_LARGE, TEXT_COLOR_SUCCESS));
+ gauge.spawn(create_text("0.0", FONT_SIZE_LARGE, TEXT_COLOR_SUCCESS));
gauge.spawn(create_text("KTS", FONT_SIZE_SMALL, TEXT_COLOR_SECONDARY));
});
diff --git a/crates/components/src/vessel_data.rs b/crates/components/src/vessel_data.rs
index d5a7313..9726ba2 100644
--- a/crates/components/src/vessel_data.rs
+++ b/crates/components/src/vessel_data.rs
@@ -31,14 +31,31 @@ impl Default for VesselData {
}
}
-/// Updates yacht data with simulated sensor readings
+/// Updates yacht data with sensor readings, using real GPS data when available
pub fn update_vessel_data(mut vessel_data: ResMut, time: Res) {
+ update_vessel_data_with_gps(vessel_data, time, None);
+}
+
+/// Updates yacht data with sensor readings, optionally using real GPS data
+pub fn update_vessel_data_with_gps(
+ mut vessel_data: ResMut,
+ time: Res,
+ gps_data: Option<(f64, f64)> // (speed, heading)
+) {
let t = time.elapsed_secs();
- // Simulate realistic yacht data with some variation
- vessel_data.speed = 12.5 + (t * 0.3).sin() * 2.0;
+ // Use real GPS data if available, otherwise simulate
+ if let Some((gps_speed, gps_heading)) = gps_data {
+ vessel_data.speed = gps_speed as f32;
+ vessel_data.heading = gps_heading as f32;
+ } else {
+ // Simulate realistic yacht data with some variation
+ vessel_data.speed = 12.5 + (t * 0.3).sin() * 2.0;
+ vessel_data.heading = (vessel_data.heading + time.delta_secs() * 5.0) % 360.0;
+ }
+
+ // Continue simulating other sensor data
vessel_data.depth = 15.2 + (t * 0.1).sin() * 3.0;
- vessel_data.heading = (vessel_data.heading + time.delta_secs() * 5.0) % 360.0;
vessel_data.engine_temp = 82.0 + (t * 0.2).sin() * 3.0;
vessel_data.wind_speed = 8.3 + (t * 0.4).sin() * 1.5;
vessel_data.wind_direction = (vessel_data.wind_direction + time.delta_secs() * 10.0) % 360.0;
diff --git a/crates/datalink-provider/src/gpyes/mod.rs b/crates/datalink-provider/src/gpyes/mod.rs
new file mode 100644
index 0000000..539f194
--- /dev/null
+++ b/crates/datalink-provider/src/gpyes/mod.rs
@@ -0,0 +1,564 @@
+use std::collections::VecDeque;
+use std::sync::{Arc, Mutex};
+use log::info;
+use serde::{Deserialize, Serialize};
+use tokio::sync::mpsc;
+use datalink::{DataLinkConfig, DataLinkError, DataLinkReceiver, DataLinkResult, DataLinkStatus, DataLinkTransmitter, DataMessage};
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct LocationData {
+ pub latitude: Option,
+ pub longitude: Option,
+ pub altitude: Option,
+ pub speed: Option,
+ pub timestamp: Option,
+ pub fix_quality: Option,
+ pub satellites: Option,
+}
+
+impl Default for LocationData {
+ fn default() -> Self {
+ LocationData {
+ latitude: None,
+ longitude: None,
+ altitude: None,
+ speed: None,
+ timestamp: None,
+ fix_quality: None,
+ satellites: None,
+ }
+ }
+}
+
+pub struct GnssParser;
+
+impl GnssParser {
+ pub fn new() -> Self {
+ GnssParser
+ }
+
+ pub fn parse_sentence(&self, sentence: &str) -> Option {
+ 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 {
+ 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::() {
+ 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::() {
+ 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::() {
+ location.fix_quality = Some(quality);
+ }
+ }
+
+ // Parse number of satellites (field 7)
+ if !parts[7].is_empty() {
+ if let Ok(sats) = parts[7].parse::() {
+ location.satellites = Some(sats);
+ }
+ }
+
+ // Parse altitude (field 9)
+ if !parts[9].is_empty() {
+ if let Ok(alt) = parts[9].parse::() {
+ location.altitude = Some(alt);
+ }
+ }
+
+ Some(location)
+ }
+
+ fn parse_gprmc(&self, parts: &[&str]) -> Option {
+ 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::() {
+ 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::() {
+ 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::() {
+ location.speed = Some(speed_knots);
+ }
+ }
+
+ Some(location)
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum GpyesSourceConfig {
+ Serial {
+ port: String,
+ baud_rate: u32,
+ },
+ Tcp {
+ host: String,
+ port: u16,
+ },
+ Udp {
+ bind_addr: String,
+ port: u16,
+ },
+ File {
+ path: String,
+ replay_speed: f64,
+ },
+}
+
+pub struct GpyesDataLinkProvider {
+ status: DataLinkStatus,
+ message_queue: Arc>>,
+ shutdown_tx: Option>,
+ parser: GnssParser,
+}
+
+impl GpyesDataLinkProvider {
+ pub fn new() -> Self {
+ GpyesDataLinkProvider {
+ status: DataLinkStatus::Disconnected,
+ message_queue: Arc::new(Mutex::new(VecDeque::new())),
+ shutdown_tx: None,
+ parser: GnssParser::new(),
+ }
+ }
+
+ pub fn parse_source_config(config: &DataLinkConfig) -> DataLinkResult {
+ let connection_type = config.parameters.get("connection_type")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing connection_type parameter".to_string()))?;
+
+ match connection_type.as_str() {
+ "serial" => {
+ let port = config.parameters.get("port")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing port parameter for serial connection".to_string()))?
+ .clone();
+
+ let baud_rate = config.parameters.get("baud_rate")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing baud_rate parameter for serial connection".to_string()))?
+ .parse::()
+ .map_err(|_| DataLinkError::InvalidConfig("Invalid baud_rate parameter".to_string()))?;
+
+ Ok(GpyesSourceConfig::Serial { port, baud_rate })
+ }
+ "tcp" => {
+ let host = config.parameters.get("host")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing host parameter for TCP connection".to_string()))?
+ .clone();
+
+ let port = config.parameters.get("port")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing port parameter for TCP connection".to_string()))?
+ .parse::()
+ .map_err(|_| DataLinkError::InvalidConfig("Invalid port parameter".to_string()))?;
+
+ Ok(GpyesSourceConfig::Tcp { host, port })
+ }
+ "udp" => {
+ let bind_addr = config.parameters.get("bind_addr")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing bind_addr parameter for UDP connection".to_string()))?
+ .clone();
+
+ let port = config.parameters.get("port")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing port parameter for UDP connection".to_string()))?
+ .parse::()
+ .map_err(|_| DataLinkError::InvalidConfig("Invalid port parameter".to_string()))?;
+
+ Ok(GpyesSourceConfig::Udp { bind_addr, port })
+ }
+ "file" => {
+ let path = config.parameters.get("path")
+ .ok_or_else(|| DataLinkError::InvalidConfig("Missing path parameter for file connection".to_string()))?
+ .clone();
+
+ let replay_speed = config.parameters.get("replay_speed")
+ .unwrap_or(&"1.0".to_string())
+ .parse::()
+ .map_err(|_| DataLinkError::InvalidConfig("Invalid replay_speed parameter".to_string()))?;
+
+ Ok(GpyesSourceConfig::File { path, replay_speed })
+ }
+ _ => Err(DataLinkError::InvalidConfig(format!("Unsupported connection type: {}", connection_type)))
+ }
+ }
+
+ fn start_receiver(&mut self) -> DataLinkResult<()> {
+ // Implementation would be similar to GPS provider but using gpyes parser
+ // For now, just set status to connected
+ self.status = DataLinkStatus::Connected;
+ Ok(())
+ }
+
+ fn location_data_to_message(&self, location: &LocationData) -> DataMessage {
+ let mut message = DataMessage::new(
+ "GPYES_LOCATION".to_string(),
+ "GPYES_RECEIVER".to_string(),
+ Vec::new(),
+ );
+
+ if let Some(lat) = location.latitude {
+ message = message.with_data("latitude".to_string(), lat.to_string());
+ }
+ if let Some(lon) = location.longitude {
+ message = message.with_data("longitude".to_string(), lon.to_string());
+ }
+ if let Some(alt) = location.altitude {
+ message = message.with_data("altitude".to_string(), alt.to_string());
+ }
+ if let Some(speed) = location.speed {
+ message = message.with_data("speed".to_string(), speed.to_string());
+ }
+ if let Some(timestamp) = &location.timestamp {
+ message = message.with_data("timestamp".to_string(), timestamp.clone());
+ }
+ if let Some(quality) = location.fix_quality {
+ message = message.with_data("fix_quality".to_string(), quality.to_string());
+ }
+ if let Some(sats) = location.satellites {
+ message = message.with_data("satellites".to_string(), sats.to_string());
+ }
+
+ message
+ }
+
+ pub fn parse_gpyes_sentence(&self, sentence: &str) -> Option {
+ if let Some(location) = self.parser.parse_sentence(sentence) {
+ Some(self.location_data_to_message(&location))
+ } else {
+ None
+ }
+ }
+
+ fn stop_receiver(&mut self) {
+ if let Some(shutdown_tx) = self.shutdown_tx.take() {
+ let _ = shutdown_tx.try_send(());
+ }
+ self.status = DataLinkStatus::Disconnected;
+ }
+}
+
+impl Default for GpyesDataLinkProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl DataLinkReceiver for GpyesDataLinkProvider {
+ fn status(&self) -> DataLinkStatus {
+ self.status.clone()
+ }
+
+ fn receive_message(&mut self) -> DataLinkResult> {
+ if let Ok(mut queue) = self.message_queue.lock() {
+ Ok(queue.pop_front())
+ } else {
+ Err(DataLinkError::TransportError("Failed to access message queue".to_string()))
+ }
+ }
+
+ fn connect(&mut self, config: &DataLinkConfig) -> DataLinkResult<()> {
+ info!("Connecting GPYES data link provider with config: {:?}", config);
+
+ let _source_config = Self::parse_source_config(config)?;
+
+ self.status = DataLinkStatus::Connecting;
+
+ // Start the receiver
+ self.start_receiver()?;
+
+ info!("GPYES data link provider connected successfully");
+ Ok(())
+ }
+
+ fn disconnect(&mut self) -> DataLinkResult<()> {
+ info!("Disconnecting GPYES data link provider");
+
+ self.stop_receiver();
+
+ info!("GPYES data link provider disconnected");
+ Ok(())
+ }
+}
+
+impl DataLinkTransmitter for GpyesDataLinkProvider {
+ fn status(&self) -> DataLinkStatus {
+ self.status.clone()
+ }
+
+ fn send_message(&mut self, _message: &DataMessage) -> DataLinkResult<()> {
+ // GPYES provider is primarily a receiver, but we implement this for completeness
+ Err(DataLinkError::TransportError("GPYES provider does not support message transmission".to_string()))
+ }
+
+ fn connect(&mut self, config: &DataLinkConfig) -> DataLinkResult<()> {
+ DataLinkReceiver::connect(self, config)
+ }
+
+ fn disconnect(&mut self) -> DataLinkResult<()> {
+ DataLinkReceiver::disconnect(self)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parser_creation() {
+ let _parser = GnssParser::new();
+ // Parser should be created successfully
+ }
+
+ #[test]
+ fn test_parse_gpgga_sentence() {
+ let parser = GnssParser::new();
+
+ // Example GPGGA sentence: Global Positioning System Fix Data
+ 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.fix_quality.is_some());
+ assert!(location.satellites.is_some());
+
+ // Check specific values
+ assert!((location.latitude.unwrap() - 48.1173).abs() < 0.001); // 4807.038N
+ assert!((location.longitude.unwrap() - 11.5167).abs() < 0.001); // 01131.000E
+ assert!((location.altitude.unwrap() - 545.4).abs() < 0.1);
+ assert_eq!(location.fix_quality.unwrap(), 1);
+ assert_eq!(location.satellites.unwrap(), 8);
+ }
+
+ #[test]
+ fn test_parse_gprmc_sentence() {
+ let parser = GnssParser::new();
+
+ // Example GPRMC sentence: Recommended Minimum Navigation Information
+ let sentence = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
+
+ 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.speed.is_some());
+ assert!(location.timestamp.is_some());
+
+ // Check specific values
+ assert!((location.latitude.unwrap() - 48.1173).abs() < 0.001);
+ assert!((location.longitude.unwrap() - 11.5167).abs() < 0.001);
+ assert!((location.speed.unwrap() - 22.4).abs() < 0.1);
+ }
+
+ #[test]
+ fn test_parse_gngga_sentence() {
+ let parser = GnssParser::new();
+
+ // Example GNGGA sentence (modern GNSS format)
+ let sentence = "$GNGGA,144751.00,3708.15162,N,07621.52868,W,1,06,1.39,-14.3,M,-35.8,M,,*69";
+
+ 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.fix_quality.is_some());
+ assert!(location.satellites.is_some());
+
+ // Check specific values
+ assert!((location.latitude.unwrap() - 37.1359).abs() < 0.001); // 3708.15162N
+ assert!((location.longitude.unwrap() - (-76.3588)).abs() < 0.001); // 07621.52868W
+ assert!((location.altitude.unwrap() - (-14.3)).abs() < 0.1);
+ assert_eq!(location.fix_quality.unwrap(), 1);
+ assert_eq!(location.satellites.unwrap(), 6);
+ }
+
+ #[test]
+ fn test_parse_gnrmc_sentence() {
+ let parser = GnssParser::new();
+
+ // Example GNRMC sentence (modern GNSS format)
+ let sentence = "$GNRMC,144751.00,A,3708.15162,N,07621.52868,W,0.009,,200725,,,A,V*01";
+
+ 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.speed.is_some());
+ assert!(location.timestamp.is_some());
+
+ // Check specific values
+ assert!((location.latitude.unwrap() - 37.1359).abs() < 0.001);
+ assert!((location.longitude.unwrap() - (-76.3588)).abs() < 0.001);
+ assert!((location.speed.unwrap() - 0.009).abs() < 0.001);
+ }
+
+ #[test]
+ fn test_parse_invalid_sentence() {
+ let parser = GnssParser::new();
+
+ let invalid_sentence = "invalid sentence";
+ let result = parser.parse_sentence(invalid_sentence);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn test_parse_empty_sentence() {
+ let parser = GnssParser::new();
+
+ let result = parser.parse_sentence("");
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn test_location_data_default() {
+ let location = LocationData::default();
+ assert!(location.latitude.is_none());
+ assert!(location.longitude.is_none());
+ assert!(location.altitude.is_none());
+ assert!(location.speed.is_none());
+ assert!(location.timestamp.is_none());
+ assert!(location.fix_quality.is_none());
+ assert!(location.satellites.is_none());
+ }
+
+ #[test]
+ fn test_gpyes_provider_creation() {
+ let provider = GpyesDataLinkProvider::new();
+ assert!(matches!(DataLinkReceiver::status(&provider), DataLinkStatus::Disconnected));
+ }
+
+ #[test]
+ fn test_location_data_to_message() {
+ let provider = GpyesDataLinkProvider::new();
+ let mut location = LocationData::default();
+ location.latitude = Some(48.1173);
+ location.longitude = Some(11.5167);
+ location.altitude = Some(545.4);
+ location.fix_quality = Some(1);
+ location.satellites = Some(8);
+
+ let message = provider.location_data_to_message(&location);
+ assert_eq!(message.message_type, "GPYES_LOCATION");
+ assert_eq!(message.source_id, "GPYES_RECEIVER");
+ assert_eq!(message.get_data("latitude"), Some(&"48.1173".to_string()));
+ assert_eq!(message.get_data("longitude"), Some(&"11.5167".to_string()));
+ assert_eq!(message.get_data("altitude"), Some(&"545.4".to_string()));
+ assert_eq!(message.get_data("fix_quality"), Some(&"1".to_string()));
+ assert_eq!(message.get_data("satellites"), Some(&"8".to_string()));
+ }
+
+ #[test]
+ fn test_parse_gpyes_sentence() {
+ let provider = GpyesDataLinkProvider::new();
+ let sentence = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47";
+
+ let message = provider.parse_gpyes_sentence(sentence);
+ assert!(message.is_some());
+
+ let msg = message.unwrap();
+ assert_eq!(msg.message_type, "GPYES_LOCATION");
+ assert_eq!(msg.source_id, "GPYES_RECEIVER");
+ assert!(msg.get_data("latitude").is_some());
+ assert!(msg.get_data("longitude").is_some());
+ assert!(msg.get_data("altitude").is_some());
+ }
+}
\ No newline at end of file
diff --git a/crates/hardware/Cargo.toml b/crates/hardware/Cargo.toml
new file mode 100644
index 0000000..9602ffd
--- /dev/null
+++ b/crates/hardware/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "hardware"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+async-trait = "0.1"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+thiserror = "1.0"
+tokio = { version = "1.0", features = ["full"] }
+uuid = { version = "1.0", features = ["v4"] }
+tracing = "0.1"
+
+[dev-dependencies]
+tokio-test = "0.4"
diff --git a/crates/hardware/README.md b/crates/hardware/README.md
new file mode 100644
index 0000000..dffb3bb
--- /dev/null
+++ b/crates/hardware/README.md
@@ -0,0 +1,366 @@
+# Virtual Hardware Abstraction Layer - Integration Guide
+
+This document provides detailed instructions on how to integrate the virtual hardware abstraction layer into yachtpit systems.
+
+## Overview
+
+The virtual hardware abstraction layer consists of three main components:
+
+1. **Hardware Bus** - Communication infrastructure for virtual devices
+2. **System Device** - Interface and base implementation for virtual hardware devices
+3. **Discovery Protocol** - Device discovery and capability advertisement
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Yachtpit Application │
+├─────────────────────────────────────────────────────────────┤
+│ Systems Crate │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ GPS System │ │Radar System │ │ AIS System │ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ Hardware Abstraction Layer │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │Hardware Bus │ │System Device│ │Discovery │ │
+│ │ │ │Interface │ │Protocol │ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Integration Steps
+
+### Step 1: Add Hardware Dependency
+
+Add the hardware crate as a dependency to the systems crate:
+
+```toml
+# crates/systems/Cargo.toml
+[dependencies]
+hardware = { path = "../hardware" }
+```
+
+### Step 2: Create Hardware-Aware System Implementations
+
+Modify existing systems to implement the `SystemDevice` trait:
+
+```rust
+// crates/systems/src/gps/gps_system.rs
+use hardware::prelude::*;
+
+pub struct GpsSystemDevice {
+ base: BaseSystemDevice,
+ // GPS-specific fields
+ position: Option,
+ satellites: u8,
+}
+
+#[async_trait::async_trait]
+impl SystemDevice for GpsSystemDevice {
+ async fn initialize(&mut self) -> Result<()> {
+ self.base.initialize().await?;
+ // GPS-specific initialization
+ self.satellites = 0;
+ Ok(())
+ }
+
+ async fn process(&mut self) -> Result> {
+ // Generate GPS data messages
+ let mut messages = Vec::new();
+
+ if let Some(position) = &self.position {
+ let payload = serde_json::to_vec(&position)?;
+ let message = BusMessage::Data {
+ from: self.base.info.address.clone(),
+ to: BusAddress::new("navigation_system"), // Example target
+ payload,
+ message_id: Uuid::new_v4(),
+ };
+ messages.push(message);
+ }
+
+ Ok(messages)
+ }
+
+ // Implement other required methods...
+}
+```
+
+### Step 3: Set Up Hardware Bus
+
+Create a central hardware bus manager:
+
+```rust
+// crates/systems/src/hardware_manager.rs
+use hardware::prelude::*;
+use std::sync::Arc;
+
+pub struct HardwareManager {
+ bus: Arc,
+ device_manager: DeviceManager,
+ discovery_protocol: DiscoveryProtocol,
+}
+
+impl HardwareManager {
+ pub async fn new() -> Result {
+ let bus = Arc::new(HardwareBus::new());
+ let device_manager = DeviceManager::new();
+
+ // Create discovery protocol for the manager itself
+ let manager_info = DeviceInfo {
+ address: BusAddress::new("hardware_manager"),
+ config: DeviceConfig {
+ name: "Hardware Manager".to_string(),
+ capabilities: vec![DeviceCapability::Communication],
+ ..Default::default()
+ },
+ status: DeviceStatus::Online,
+ last_seen: SystemTime::now(),
+ version: "1.0.0".to_string(),
+ manufacturer: "Yachtpit".to_string(),
+ };
+
+ let discovery_protocol = DiscoveryProtocol::new(
+ manager_info,
+ DiscoveryConfig::default(),
+ );
+
+ Ok(Self {
+ bus,
+ device_manager,
+ discovery_protocol,
+ })
+ }
+
+ pub async fn add_system_device(&mut self, device: Box) -> Result<()> {
+ let address = device.get_info().address.clone();
+
+ // Connect device to bus
+ let connection = self.bus.connect_device(address.clone()).await?;
+
+ // Add to device manager
+ self.device_manager.add_device(device);
+
+ Ok(())
+ }
+
+ pub async fn start_all_systems(&mut self) -> Result<()> {
+ self.device_manager.start_all().await?;
+ self.discovery_protocol.start().await?;
+ Ok(())
+ }
+}
+```
+
+### Step 4: Integrate with Existing Systems
+
+Modify the existing vessel systems to use the hardware abstraction:
+
+```rust
+// crates/systems/src/vessel/vessel_systems.rs
+use crate::hardware_manager::HardwareManager;
+
+pub async fn create_vessel_systems_with_hardware() -> Result {
+ let mut hardware_manager = HardwareManager::new().await?;
+
+ // Create GPS system
+ let gps_config = DeviceConfig {
+ name: "GPS System".to_string(),
+ capabilities: vec![DeviceCapability::Gps],
+ update_interval_ms: 1000,
+ ..Default::default()
+ };
+ let gps_device = Box::new(GpsSystemDevice::new(gps_config));
+ hardware_manager.add_system_device(gps_device).await?;
+
+ // Create Radar system
+ let radar_config = DeviceConfig {
+ name: "Radar System".to_string(),
+ capabilities: vec![DeviceCapability::Radar],
+ update_interval_ms: 500,
+ ..Default::default()
+ };
+ let radar_device = Box::new(RadarSystemDevice::new(radar_config));
+ hardware_manager.add_system_device(radar_device).await?;
+
+ // Create AIS system
+ let ais_config = DeviceConfig {
+ name: "AIS System".to_string(),
+ capabilities: vec![DeviceCapability::Ais],
+ update_interval_ms: 2000,
+ ..Default::default()
+ };
+ let ais_device = Box::new(AisSystemDevice::new(ais_config));
+ hardware_manager.add_system_device(ais_device).await?;
+
+ hardware_manager.start_all_systems().await?;
+
+ Ok(hardware_manager)
+}
+```
+
+### Step 5: Update Main Application
+
+Integrate the hardware manager into the main yachtpit application:
+
+```rust
+// crates/yachtpit/src/core/system_manager.rs
+use systems::vessel::vessel_systems::create_vessel_systems_with_hardware;
+
+pub struct SystemManager {
+ hardware_manager: Option,
+}
+
+impl SystemManager {
+ pub async fn initialize_with_hardware(&mut self) -> Result<()> {
+ let hardware_manager = create_vessel_systems_with_hardware().await?;
+ self.hardware_manager = Some(hardware_manager);
+ Ok(())
+ }
+
+ pub async fn discover_devices(&self) -> Result> {
+ if let Some(ref manager) = self.hardware_manager {
+ // Use discovery protocol to find devices
+ manager.discovery_protocol.discover_devices(None).await?;
+ tokio::time::sleep(Duration::from_millis(100)).await; // Wait for responses
+ Ok(manager.discovery_protocol.get_known_devices().await)
+ } else {
+ Ok(vec![])
+ }
+ }
+}
+```
+
+## Message Flow Examples
+
+### GPS Data Flow
+```
+GPS Device → Hardware Bus → Navigation System
+ → Discovery Protocol (heartbeat)
+ → Other interested devices
+```
+
+### Device Discovery Flow
+```
+New Device → Announce Message → Hardware Bus → All Devices
+Discovery Request → Hardware Bus → Matching Devices → Response
+```
+
+## Configuration
+
+### Device Configuration
+Each device can be configured with:
+- Update intervals
+- Capabilities
+- Custom configuration parameters
+- Message queue sizes
+
+### Discovery Configuration
+- Heartbeat intervals
+- Device timeout periods
+- Cleanup intervals
+- Maximum tracked devices
+
+## Testing Integration
+
+### Unit Tests
+Run tests for individual components:
+```bash
+cargo test -p hardware
+cargo test -p systems
+```
+
+### Integration Tests
+Create integration tests that verify the complete flow:
+
+```rust
+#[tokio::test]
+async fn test_complete_hardware_integration() {
+ let mut hardware_manager = HardwareManager::new().await.unwrap();
+
+ // Add test devices
+ let gps_device = Box::new(create_test_gps_device());
+ hardware_manager.add_system_device(gps_device).await.unwrap();
+
+ // Start systems
+ hardware_manager.start_all_systems().await.unwrap();
+
+ // Verify device discovery
+ let devices = hardware_manager.discovery_protocol.get_known_devices().await;
+ assert!(!devices.is_empty());
+
+ // Test message passing
+ // ... additional test logic
+}
+```
+
+## Performance Considerations
+
+1. **Message Throughput**: The hardware bus uses unbounded channels for high throughput
+2. **Device Limits**: Configure maximum device limits based on system resources
+3. **Update Intervals**: Balance between data freshness and system load
+4. **Memory Usage**: Monitor device registry size and message history
+
+## Error Handling
+
+The hardware abstraction layer provides comprehensive error handling:
+
+- **Device Errors**: Automatic retry and fallback mechanisms
+- **Bus Errors**: Connection recovery and message queuing
+- **Discovery Errors**: Timeout handling and device cleanup
+
+## Migration Strategy
+
+### Phase 1: Parallel Implementation
+- Keep existing systems running
+- Implement hardware abstraction alongside
+- Gradual migration of individual systems
+
+### Phase 2: Feature Parity
+- Ensure all existing functionality is available
+- Add comprehensive testing
+- Performance validation
+
+### Phase 3: Full Migration
+- Switch to hardware abstraction as primary
+- Remove legacy system implementations
+- Optimize performance
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Device Not Found**: Check device registration and bus connection
+2. **Message Delivery Failures**: Verify device addresses and bus connectivity
+3. **Discovery Timeouts**: Adjust discovery configuration parameters
+4. **Performance Issues**: Monitor message queue sizes and update intervals
+
+### Debugging Tools
+
+```rust
+// Enable debug logging
+use tracing::{info, debug, warn};
+
+// Check device status
+// let device_info = hardware_manager.get_device_info(&address).await;
+// debug!("Device status: {:?}", device_info.status);
+
+// Monitor message history
+// let messages = hardware_bus.get_message_history().await;
+// info!("Recent messages: {}", messages.len());
+```
+
+## Future Enhancements
+
+1. **Network Discovery**: Extend discovery protocol to work across network boundaries
+2. **Device Simulation**: Add comprehensive device simulators for testing
+3. **Hot-Plugging**: Support for dynamic device addition/removal
+4. **Load Balancing**: Distribute device processing across multiple threads
+5. **Persistence**: Save and restore device configurations and state
+
+## Conclusion
+
+The virtual hardware abstraction layer provides a robust foundation for managing yacht systems. By following this integration guide, you can gradually migrate existing systems while maintaining full functionality and adding new capabilities for device discovery and communication.
+
+For questions or issues during integration, refer to the individual module documentation in the hardware crate or create an issue in the project repository.
diff --git a/crates/hardware/src/bus.rs b/crates/hardware/src/bus.rs
new file mode 100644
index 0000000..c195c10
--- /dev/null
+++ b/crates/hardware/src/bus.rs
@@ -0,0 +1,338 @@
+//! Virtual Hardware Bus Module
+//!
+//! Provides a communication infrastructure for virtual hardware devices
+
+use crate::{HardwareError, Result};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::sync::Arc;
+use tokio::sync::{mpsc, RwLock};
+use tracing::{debug, error, info, warn};
+use uuid::Uuid;
+
+/// Unique address for devices on the hardware bus
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct BusAddress {
+ pub id: Uuid,
+ pub name: String,
+}
+
+impl BusAddress {
+ /// Create a new bus address with a generated UUID
+ pub fn new(name: impl Into) -> Self {
+ Self {
+ id: Uuid::new_v4(),
+ name: name.into(),
+ }
+ }
+
+ /// Create a bus address with a specific UUID
+ pub fn with_id(id: Uuid, name: impl Into) -> Self {
+ Self {
+ id,
+ name: name.into(),
+ }
+ }
+}
+
+/// Message types that can be sent over the hardware bus
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum BusMessage {
+ /// Data message with payload
+ Data {
+ from: BusAddress,
+ to: BusAddress,
+ payload: Vec,
+ message_id: Uuid,
+ },
+ /// Control message for bus management
+ Control {
+ from: BusAddress,
+ command: ControlCommand,
+ message_id: Uuid,
+ },
+ /// Broadcast message to all devices
+ Broadcast {
+ from: BusAddress,
+ payload: Vec,
+ message_id: Uuid,
+ },
+ /// Acknowledgment message
+ Ack {
+ to: BusAddress,
+ original_message_id: Uuid,
+ message_id: Uuid,
+ },
+}
+
+/// Control commands for bus management
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum ControlCommand {
+ /// Register a device on the bus
+ Register { address: BusAddress },
+ /// Unregister a device from the bus
+ Unregister { address: BusAddress },
+ /// Ping a device
+ Ping { target: BusAddress },
+ /// Pong response to ping
+ Pong { from: BusAddress },
+ /// Request device list
+ ListDevices,
+ /// Response with device list
+ DeviceList { devices: Vec },
+}
+
+impl BusMessage {
+ /// Get the message ID
+ pub fn message_id(&self) -> Uuid {
+ match self {
+ BusMessage::Data { message_id, .. } => *message_id,
+ BusMessage::Control { message_id, .. } => *message_id,
+ BusMessage::Broadcast { message_id, .. } => *message_id,
+ BusMessage::Ack { message_id, .. } => *message_id,
+ }
+ }
+
+ /// Get the sender address if available
+ pub fn from(&self) -> Option<&BusAddress> {
+ match self {
+ BusMessage::Data { from, .. } => Some(from),
+ BusMessage::Control { from, .. } => Some(from),
+ BusMessage::Broadcast { from, .. } => Some(from),
+ BusMessage::Ack { .. } => None,
+ }
+ }
+}
+
+/// Device connection handle for the hardware bus
+pub struct DeviceConnection {
+ pub address: BusAddress,
+ pub sender: mpsc::UnboundedSender,
+ pub receiver: mpsc::UnboundedReceiver,
+}
+
+/// Virtual Hardware Bus implementation
+pub struct HardwareBus {
+ devices: Arc>>>,
+ message_log: Arc>>,
+}
+
+impl Default for HardwareBus {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl HardwareBus {
+ /// Create a new hardware bus
+ pub fn new() -> Self {
+ Self {
+ devices: Arc::new(RwLock::new(HashMap::new())),
+ message_log: Arc::new(RwLock::new(Vec::new())),
+ }
+ }
+
+ /// Connect a device to the bus
+ pub async fn connect_device(&self, address: BusAddress) -> Result {
+ let (tx, rx) = mpsc::unbounded_channel();
+
+ {
+ let mut devices = self.devices.write().await;
+ if devices.contains_key(&address) {
+ return Err(HardwareError::generic(format!(
+ "Device {} already connected", address.name
+ )));
+ }
+ devices.insert(address.clone(), tx.clone());
+ }
+
+ info!("Device {} connected to bus", address.name);
+
+ // Send registration message to all other devices
+ let register_msg = BusMessage::Control {
+ from: address.clone(),
+ command: ControlCommand::Register {
+ address: address.clone(),
+ },
+ message_id: Uuid::new_v4(),
+ };
+
+ self.broadcast_message(register_msg).await?;
+
+ Ok(DeviceConnection {
+ address,
+ sender: tx,
+ receiver: rx,
+ })
+ }
+
+ /// Disconnect a device from the bus
+ pub async fn disconnect_device(&self, address: &BusAddress) -> Result<()> {
+ {
+ let mut devices = self.devices.write().await;
+ devices.remove(address);
+ }
+
+ info!("Device {} disconnected from bus", address.name);
+
+ // Send unregistration message to all other devices
+ let unregister_msg = BusMessage::Control {
+ from: address.clone(),
+ command: ControlCommand::Unregister {
+ address: address.clone(),
+ },
+ message_id: Uuid::new_v4(),
+ };
+
+ self.broadcast_message(unregister_msg).await?;
+
+ Ok(())
+ }
+
+ /// Send a message to a specific device
+ pub async fn send_message(&self, message: BusMessage) -> Result<()> {
+ // Log the message
+ {
+ let mut log = self.message_log.write().await;
+ log.push(message.clone());
+ }
+
+ match &message {
+ BusMessage::Data { to, .. } => {
+ let devices = self.devices.read().await;
+ if let Some(sender) = devices.get(to) {
+ sender.send(message).map_err(|_| {
+ HardwareError::bus_communication("Failed to send message to device")
+ })?;
+ } else {
+ return Err(HardwareError::device_not_found(&to.name));
+ }
+ }
+ BusMessage::Broadcast { .. } => {
+ self.broadcast_message(message).await?;
+ }
+ BusMessage::Control { .. } => {
+ self.broadcast_message(message).await?;
+ }
+ BusMessage::Ack { to, .. } => {
+ let devices = self.devices.read().await;
+ if let Some(sender) = devices.get(to) {
+ sender.send(message).map_err(|_| {
+ HardwareError::bus_communication("Failed to send ACK to device")
+ })?;
+ } else {
+ warn!("Attempted to send ACK to unknown device: {}", to.name);
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Broadcast a message to all connected devices
+ async fn broadcast_message(&self, message: BusMessage) -> Result<()> {
+ let devices = self.devices.read().await;
+ let sender_address = message.from();
+
+ for (address, sender) in devices.iter() {
+ // Don't send message back to sender
+ if let Some(from) = sender_address {
+ if address == from {
+ continue;
+ }
+ }
+
+ if let Err(_) = sender.send(message.clone()) {
+ error!("Failed to broadcast message to device: {}", address.name);
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Get list of connected devices
+ pub async fn get_connected_devices(&self) -> Vec {
+ let devices = self.devices.read().await;
+ devices.keys().cloned().collect()
+ }
+
+ /// Get message history
+ pub async fn get_message_history(&self) -> Vec {
+ let log = self.message_log.read().await;
+ log.clone()
+ }
+
+ /// Clear message history
+ pub async fn clear_message_history(&self) {
+ let mut log = self.message_log.write().await;
+ log.clear();
+ }
+
+ /// Check if a device is connected
+ pub async fn is_device_connected(&self, address: &BusAddress) -> bool {
+ let devices = self.devices.read().await;
+ devices.contains_key(address)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tokio_test;
+
+ #[tokio::test]
+ async fn test_bus_creation() {
+ let bus = HardwareBus::new();
+ assert_eq!(bus.get_connected_devices().await.len(), 0);
+ }
+
+ #[tokio::test]
+ async fn test_device_connection() {
+ let bus = HardwareBus::new();
+ let address = BusAddress::new("test_device");
+
+ let connection = bus.connect_device(address.clone()).await.unwrap();
+ assert_eq!(connection.address, address);
+ assert!(bus.is_device_connected(&address).await);
+ }
+
+ #[tokio::test]
+ async fn test_device_disconnection() {
+ let bus = HardwareBus::new();
+ let address = BusAddress::new("test_device");
+
+ let _connection = bus.connect_device(address.clone()).await.unwrap();
+ assert!(bus.is_device_connected(&address).await);
+
+ bus.disconnect_device(&address).await.unwrap();
+ assert!(!bus.is_device_connected(&address).await);
+ }
+
+ #[tokio::test]
+ async fn test_message_sending() {
+ let bus = HardwareBus::new();
+ let addr1 = BusAddress::new("device1");
+ let addr2 = BusAddress::new("device2");
+
+ let mut conn1 = bus.connect_device(addr1.clone()).await.unwrap();
+ let _conn2 = bus.connect_device(addr2.clone()).await.unwrap();
+
+ let message = BusMessage::Data {
+ from: addr2.clone(),
+ to: addr1.clone(),
+ payload: b"test data".to_vec(),
+ message_id: Uuid::new_v4(),
+ };
+
+ bus.send_message(message.clone()).await.unwrap();
+
+ // Check if message was received
+ let received = conn1.receiver.recv().await.unwrap();
+ match received {
+ BusMessage::Data { payload, .. } => {
+ assert_eq!(payload, b"test data");
+ }
+ _ => panic!("Expected data message"),
+ }
+ }
+}
\ No newline at end of file
diff --git a/crates/hardware/src/device.rs b/crates/hardware/src/device.rs
new file mode 100644
index 0000000..21cb826
--- /dev/null
+++ b/crates/hardware/src/device.rs
@@ -0,0 +1,430 @@
+//! System Device Module
+//!
+//! Defines the interface and behavior for virtual hardware devices
+
+use crate::{BusAddress, BusMessage, HardwareError, Result};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::time::{Duration, SystemTime};
+use tokio::sync::mpsc;
+use tracing::{debug, info, warn};
+use uuid::Uuid;
+
+/// Device capabilities that can be advertised
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum DeviceCapability {
+ /// GPS positioning capability
+ Gps,
+ /// Radar detection capability
+ Radar,
+ /// AIS (Automatic Identification System) capability
+ Ais,
+ /// Engine monitoring capability
+ Engine,
+ /// Navigation capability
+ Navigation,
+ /// Communication capability
+ Communication,
+ /// Sensor data capability
+ Sensor,
+ /// Custom capability with name
+ Custom(String),
+}
+
+impl DeviceCapability {
+ /// Get the capability name as a string
+ pub fn name(&self) -> &str {
+ match self {
+ DeviceCapability::Gps => "GPS",
+ DeviceCapability::Radar => "Radar",
+ DeviceCapability::Ais => "AIS",
+ DeviceCapability::Engine => "Engine",
+ DeviceCapability::Navigation => "Navigation",
+ DeviceCapability::Communication => "Communication",
+ DeviceCapability::Sensor => "Sensor",
+ DeviceCapability::Custom(name) => name,
+ }
+ }
+}
+
+/// Current status of a device
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum DeviceStatus {
+ /// Device is initializing
+ Initializing,
+ /// Device is online and operational
+ Online,
+ /// Device is offline
+ Offline,
+ /// Device has encountered an error
+ Error { message: String },
+ /// Device is in maintenance mode
+ Maintenance,
+}
+
+/// Device configuration parameters
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DeviceConfig {
+ /// Device name
+ pub name: String,
+ /// Device capabilities
+ pub capabilities: Vec,
+ /// Update interval in milliseconds
+ pub update_interval_ms: u64,
+ /// Maximum message queue size
+ pub max_queue_size: usize,
+ /// Device-specific configuration
+ pub custom_config: HashMap,
+}
+
+impl Default for DeviceConfig {
+ fn default() -> Self {
+ Self {
+ name: "Unknown Device".to_string(),
+ capabilities: vec![],
+ update_interval_ms: 1000,
+ max_queue_size: 100,
+ custom_config: HashMap::new(),
+ }
+ }
+}
+
+/// Device information for discovery and identification
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DeviceInfo {
+ /// Device address
+ pub address: BusAddress,
+ /// Device configuration
+ pub config: DeviceConfig,
+ /// Current status
+ pub status: DeviceStatus,
+ /// Last seen timestamp
+ pub last_seen: SystemTime,
+ /// Device version
+ pub version: String,
+ /// Manufacturer information
+ pub manufacturer: String,
+}
+
+/// Trait for implementing system devices
+#[async_trait::async_trait]
+pub trait SystemDevice: Send + Sync {
+ /// Initialize the device
+ async fn initialize(&mut self) -> Result<()>;
+
+ /// Start the device operation
+ async fn start(&mut self) -> Result<()>;
+
+ /// Stop the device operation
+ async fn stop(&mut self) -> Result<()>;
+
+ /// Get device information
+ fn get_info(&self) -> DeviceInfo;
+
+ /// Get current device status
+ fn get_status(&self) -> DeviceStatus;
+
+ /// Handle incoming bus message
+ async fn handle_message(&mut self, message: BusMessage) -> Result>;
+
+ /// Process device-specific logic (called periodically)
+ async fn process(&mut self) -> Result>;
+
+ /// Get device capabilities
+ fn get_capabilities(&self) -> Vec;
+
+ /// Update device configuration
+ async fn update_config(&mut self, config: DeviceConfig) -> Result<()>;
+}
+
+/// Base implementation for system devices
+pub struct BaseSystemDevice {
+ pub info: DeviceInfo,
+ pub message_sender: Option>,
+ pub message_receiver: Option>,
+ pub is_running: bool,
+}
+
+impl BaseSystemDevice {
+ /// Create a new base system device
+ pub fn new(config: DeviceConfig) -> Self {
+ let address = BusAddress::new(&config.name);
+ let info = DeviceInfo {
+ address,
+ config,
+ status: DeviceStatus::Initializing,
+ last_seen: SystemTime::now(),
+ version: "1.0.0".to_string(),
+ manufacturer: "Virtual Hardware".to_string(),
+ };
+
+ Self {
+ info,
+ message_sender: None,
+ message_receiver: None,
+ is_running: false,
+ }
+ }
+
+ /// Set the message channels
+ pub fn set_message_channels(
+ &mut self,
+ sender: mpsc::UnboundedSender,
+ receiver: mpsc::UnboundedReceiver,
+ ) {
+ self.message_sender = Some(sender);
+ self.message_receiver = Some(receiver);
+ }
+
+ /// Send a message through the bus
+ pub async fn send_message(&self, message: BusMessage) -> Result<()> {
+ if let Some(sender) = &self.message_sender {
+ sender.send(message).map_err(|_| {
+ HardwareError::bus_communication("Failed to send message from device")
+ })?;
+ } else {
+ return Err(HardwareError::generic("Device not connected to bus"));
+ }
+ Ok(())
+ }
+
+ /// Update device status
+ pub fn set_status(&mut self, status: DeviceStatus) {
+ self.info.status = status;
+ self.info.last_seen = SystemTime::now();
+ }
+
+ /// Check if device is running
+ pub fn is_running(&self) -> bool {
+ self.is_running
+ }
+}
+
+#[async_trait::async_trait]
+impl SystemDevice for BaseSystemDevice {
+ async fn initialize(&mut self) -> Result<()> {
+ info!("Initializing device: {}", self.info.config.name);
+ self.set_status(DeviceStatus::Initializing);
+
+ // Simulate initialization delay
+ tokio::time::sleep(Duration::from_millis(100)).await;
+
+ self.set_status(DeviceStatus::Online);
+ Ok(())
+ }
+
+ async fn start(&mut self) -> Result<()> {
+ info!("Starting device: {}", self.info.config.name);
+ self.is_running = true;
+ self.set_status(DeviceStatus::Online);
+ Ok(())
+ }
+
+ async fn stop(&mut self) -> Result<()> {
+ info!("Stopping device: {}", self.info.config.name);
+ self.is_running = false;
+ self.set_status(DeviceStatus::Offline);
+ Ok(())
+ }
+
+ fn get_info(&self) -> DeviceInfo {
+ self.info.clone()
+ }
+
+ fn get_status(&self) -> DeviceStatus {
+ self.info.status.clone()
+ }
+
+ async fn handle_message(&mut self, message: BusMessage) -> Result> {
+ debug!("Device {} received message: {:?}", self.info.config.name, message);
+
+ match message {
+ BusMessage::Control { command, .. } => {
+ match command {
+ crate::bus::ControlCommand::Ping { target } => {
+ if target == self.info.address {
+ let pong = BusMessage::Control {
+ from: self.info.address.clone(),
+ command: crate::bus::ControlCommand::Pong {
+ from: self.info.address.clone(),
+ },
+ message_id: Uuid::new_v4(),
+ };
+ return Ok(Some(pong));
+ }
+ }
+ _ => {}
+ }
+ }
+ _ => {}
+ }
+
+ Ok(None)
+ }
+
+ async fn process(&mut self) -> Result> {
+ // Base implementation does nothing
+ Ok(vec![])
+ }
+
+ fn get_capabilities(&self) -> Vec {
+ self.info.config.capabilities.clone()
+ }
+
+ async fn update_config(&mut self, config: DeviceConfig) -> Result<()> {
+ info!("Updating config for device: {}", self.info.config.name);
+ self.info.config = config;
+ Ok(())
+ }
+}
+
+/// Device manager for handling multiple devices
+pub struct DeviceManager {
+ devices: HashMap>,
+}
+
+impl DeviceManager {
+ /// Create a new device manager
+ pub fn new() -> Self {
+ Self {
+ devices: HashMap::new(),
+ }
+ }
+
+ /// Add a device to the manager
+ pub fn add_device(&mut self, device: Box) {
+ let address = device.get_info().address.clone();
+ self.devices.insert(address, device);
+ }
+
+ /// Remove a device from the manager
+ pub fn remove_device(&mut self, address: &BusAddress) -> Option> {
+ self.devices.remove(address)
+ }
+
+ /// Get a device by address
+ pub fn get_device(&self, address: &BusAddress) -> Option<&dyn SystemDevice> {
+ self.devices.get(address).map(|d| d.as_ref())
+ }
+
+ /// Get a mutable device by address
+ pub fn get_device_mut(&mut self, address: &BusAddress) -> Option<&mut Box> {
+ self.devices.get_mut(address)
+ }
+
+ /// Initialize all devices
+ pub async fn initialize_all(&mut self) -> Result<()> {
+ for device in self.devices.values_mut() {
+ device.initialize().await?;
+ }
+ Ok(())
+ }
+
+ /// Start all devices
+ pub async fn start_all(&mut self) -> Result<()> {
+ for device in self.devices.values_mut() {
+ device.start().await?;
+ }
+ Ok(())
+ }
+
+ /// Stop all devices
+ pub async fn stop_all(&mut self) -> Result<()> {
+ for device in self.devices.values_mut() {
+ device.stop().await?;
+ }
+ Ok(())
+ }
+
+ /// Get all device information
+ pub fn get_all_device_info(&self) -> Vec {
+ self.devices.values().map(|d| d.get_info()).collect()
+ }
+
+ /// Process all devices
+ pub async fn process_all(&mut self) -> Result> {
+ let mut messages = Vec::new();
+ for device in self.devices.values_mut() {
+ let device_messages = device.process().await?;
+ messages.extend(device_messages);
+ }
+ Ok(messages)
+ }
+}
+
+impl Default for DeviceManager {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_device_creation() {
+ let config = DeviceConfig {
+ name: "Test Device".to_string(),
+ capabilities: vec![DeviceCapability::Gps],
+ ..Default::default()
+ };
+
+ let device = BaseSystemDevice::new(config);
+ assert_eq!(device.info.config.name, "Test Device");
+ assert_eq!(device.info.status, DeviceStatus::Initializing);
+ }
+
+ #[tokio::test]
+ async fn test_device_initialization() {
+ let config = DeviceConfig {
+ name: "Test Device".to_string(),
+ ..Default::default()
+ };
+
+ let mut device = BaseSystemDevice::new(config);
+ device.initialize().await.unwrap();
+ assert_eq!(device.get_status(), DeviceStatus::Online);
+ }
+
+ #[tokio::test]
+ async fn test_device_start_stop() {
+ let config = DeviceConfig {
+ name: "Test Device".to_string(),
+ ..Default::default()
+ };
+
+ let mut device = BaseSystemDevice::new(config);
+
+ device.start().await.unwrap();
+ assert!(device.is_running());
+ assert_eq!(device.get_status(), DeviceStatus::Online);
+
+ device.stop().await.unwrap();
+ assert!(!device.is_running());
+ assert_eq!(device.get_status(), DeviceStatus::Offline);
+ }
+
+ #[tokio::test]
+ async fn test_device_manager() {
+ let mut manager = DeviceManager::new();
+
+ let config = DeviceConfig {
+ name: "Test Device".to_string(),
+ ..Default::default()
+ };
+
+ let device = Box::new(BaseSystemDevice::new(config));
+ let address = device.get_info().address.clone();
+
+ manager.add_device(device);
+ assert!(manager.get_device(&address).is_some());
+
+ manager.initialize_all().await.unwrap();
+ manager.start_all().await.unwrap();
+
+ let info = manager.get_all_device_info();
+ assert_eq!(info.len(), 1);
+ assert_eq!(info[0].status, DeviceStatus::Online);
+ }
+}
diff --git a/crates/hardware/src/discovery_protocol.rs b/crates/hardware/src/discovery_protocol.rs
new file mode 100644
index 0000000..20e7565
--- /dev/null
+++ b/crates/hardware/src/discovery_protocol.rs
@@ -0,0 +1,561 @@
+//! Discovery Protocol Module
+//!
+//! Provides device discovery and capability advertisement functionality
+
+use crate::{BusAddress, BusMessage, DeviceCapability, DeviceInfo, HardwareError, Result};
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::time::{Duration, SystemTime};
+use tokio::sync::{mpsc, RwLock};
+use tracing::{debug, info, warn};
+use uuid::Uuid;
+use std::sync::Arc;
+
+/// Discovery message types
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum DiscoveryMessage {
+ /// Announce device presence and capabilities
+ Announce {
+ device_info: DeviceInfo,
+ timestamp: SystemTime,
+ },
+ /// Request device information
+ Discover {
+ requester: BusAddress,
+ filter: Option,
+ timestamp: SystemTime,
+ },
+ /// Response to discovery request
+ DiscoverResponse {
+ devices: Vec,
+ responder: BusAddress,
+ timestamp: SystemTime,
+ },
+ /// Heartbeat to maintain presence
+ Heartbeat {
+ device: BusAddress,
+ timestamp: SystemTime,
+ },
+ /// Device going offline notification
+ Goodbye {
+ device: BusAddress,
+ timestamp: SystemTime,
+ },
+}
+
+/// Filter criteria for device discovery
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DiscoveryFilter {
+ /// Filter by device capabilities
+ pub capabilities: Option>,
+ /// Filter by device name pattern
+ pub name_pattern: Option,
+ /// Filter by manufacturer
+ pub manufacturer: Option,
+ /// Filter by minimum version
+ pub min_version: Option,
+}
+
+impl DiscoveryFilter {
+ /// Create a new empty filter
+ pub fn new() -> Self {
+ Self {
+ capabilities: None,
+ name_pattern: None,
+ manufacturer: None,
+ min_version: None,
+ }
+ }
+
+ /// Filter by capabilities
+ pub fn with_capabilities(mut self, capabilities: Vec) -> Self {
+ self.capabilities = Some(capabilities);
+ self
+ }
+
+ /// Filter by name pattern
+ pub fn with_name_pattern(mut self, pattern: impl Into) -> Self {
+ self.name_pattern = Some(pattern.into());
+ self
+ }
+
+ /// Filter by manufacturer
+ pub fn with_manufacturer(mut self, manufacturer: impl Into) -> Self {
+ self.manufacturer = Some(manufacturer.into());
+ self
+ }
+
+ /// Check if device matches this filter
+ pub fn matches(&self, device_info: &DeviceInfo) -> bool {
+ // Check capabilities
+ if let Some(required_caps) = &self.capabilities {
+ let device_caps = &device_info.config.capabilities;
+ if !required_caps.iter().all(|cap| device_caps.contains(cap)) {
+ return false;
+ }
+ }
+
+ // Check name pattern (simple substring match)
+ if let Some(pattern) = &self.name_pattern {
+ if !device_info.config.name.contains(pattern) {
+ return false;
+ }
+ }
+
+ // Check manufacturer
+ if let Some(manufacturer) = &self.manufacturer {
+ if device_info.manufacturer != *manufacturer {
+ return false;
+ }
+ }
+
+ // Check version (simple string comparison for now)
+ if let Some(min_version) = &self.min_version {
+ if device_info.version < *min_version {
+ return false;
+ }
+ }
+
+ true
+ }
+}
+
+impl Default for DiscoveryFilter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Discovery protocol configuration
+#[derive(Debug, Clone)]
+pub struct DiscoveryConfig {
+ /// How often to send heartbeat messages (in seconds)
+ pub heartbeat_interval: Duration,
+ /// How long to wait before considering a device offline (in seconds)
+ pub device_timeout: Duration,
+ /// How often to clean up expired devices (in seconds)
+ pub cleanup_interval: Duration,
+ /// Maximum number of devices to track
+ pub max_devices: usize,
+}
+
+impl Default for DiscoveryConfig {
+ fn default() -> Self {
+ Self {
+ heartbeat_interval: Duration::from_secs(30),
+ device_timeout: Duration::from_secs(90),
+ cleanup_interval: Duration::from_secs(60),
+ max_devices: 1000,
+ }
+ }
+}
+
+/// Discovery protocol implementation
+pub struct DiscoveryProtocol {
+ /// Local device information
+ local_device: DeviceInfo,
+ /// Known devices registry
+ known_devices: Arc>>,
+ /// Configuration
+ config: DiscoveryConfig,
+ /// Message sender for bus communication
+ message_sender: Option>,
+ /// Discovery message receiver
+ discovery_receiver: Option>,
+ /// Running state
+ is_running: bool,
+}
+
+impl DiscoveryProtocol {
+ /// Create a new discovery protocol instance
+ pub fn new(local_device: DeviceInfo, config: DiscoveryConfig) -> Self {
+ Self {
+ local_device,
+ known_devices: Arc::new(RwLock::new(HashMap::new())),
+ config,
+ message_sender: None,
+ discovery_receiver: None,
+ is_running: false,
+ }
+ }
+
+ /// Set the message sender for bus communication
+ pub fn set_message_sender(&mut self, sender: mpsc::UnboundedSender) {
+ self.message_sender = Some(sender);
+ }
+
+ /// Set the discovery message receiver
+ pub fn set_discovery_receiver(&mut self, receiver: mpsc::UnboundedReceiver) {
+ self.discovery_receiver = Some(receiver);
+ }
+
+ /// Start the discovery protocol
+ pub async fn start(&mut self) -> Result<()> {
+ info!("Starting discovery protocol for device: {}", self.local_device.config.name);
+ self.is_running = true;
+
+ // Send initial announcement
+ self.announce_device().await?;
+
+ Ok(())
+ }
+
+ /// Stop the discovery protocol
+ pub async fn stop(&mut self) -> Result<()> {
+ info!("Stopping discovery protocol for device: {}", self.local_device.config.name);
+ self.is_running = false;
+
+ // Send goodbye message
+ self.send_goodbye().await?;
+
+ Ok(())
+ }
+
+ /// Announce this device to the network
+ pub async fn announce_device(&self) -> Result<()> {
+ let announcement = DiscoveryMessage::Announce {
+ device_info: self.local_device.clone(),
+ timestamp: SystemTime::now(),
+ };
+
+ self.send_discovery_message(announcement).await
+ }
+
+ /// Send heartbeat to maintain presence
+ pub async fn send_heartbeat(&self) -> Result<()> {
+ let heartbeat = DiscoveryMessage::Heartbeat {
+ device: self.local_device.address.clone(),
+ timestamp: SystemTime::now(),
+ };
+
+ self.send_discovery_message(heartbeat).await
+ }
+
+ /// Send goodbye message when going offline
+ pub async fn send_goodbye(&self) -> Result<()> {
+ let goodbye = DiscoveryMessage::Goodbye {
+ device: self.local_device.address.clone(),
+ timestamp: SystemTime::now(),
+ };
+
+ self.send_discovery_message(goodbye).await
+ }
+
+ /// Discover devices on the network
+ pub async fn discover_devices(&self, filter: Option) -> Result<()> {
+ let discover_msg = DiscoveryMessage::Discover {
+ requester: self.local_device.address.clone(),
+ filter,
+ timestamp: SystemTime::now(),
+ };
+
+ self.send_discovery_message(discover_msg).await
+ }
+
+ /// Get all known devices
+ pub async fn get_known_devices(&self) -> Vec {
+ let devices = self.known_devices.read().await;
+ devices.values().cloned().collect()
+ }
+
+ /// Get devices matching a filter
+ pub async fn get_devices_by_filter(&self, filter: &DiscoveryFilter) -> Vec {
+ let devices = self.known_devices.read().await;
+ devices
+ .values()
+ .filter(|device| filter.matches(device))
+ .cloned()
+ .collect()
+ }
+
+ /// Get device by address
+ pub async fn get_device(&self, address: &BusAddress) -> Option {
+ let devices = self.known_devices.read().await;
+ devices.get(address).cloned()
+ }
+
+ /// Handle incoming discovery message
+ pub async fn handle_discovery_message(&self, message: DiscoveryMessage) -> Result<()> {
+ match message {
+ DiscoveryMessage::Announce { device_info, .. } => {
+ self.handle_device_announcement(device_info).await
+ }
+ DiscoveryMessage::Discover { requester, filter, .. } => {
+ self.handle_discovery_request(requester, filter).await
+ }
+ DiscoveryMessage::DiscoverResponse { devices, .. } => {
+ self.handle_discovery_response(devices).await
+ }
+ DiscoveryMessage::Heartbeat { device, timestamp } => {
+ self.handle_heartbeat(device, timestamp).await
+ }
+ DiscoveryMessage::Goodbye { device, .. } => {
+ self.handle_goodbye(device).await
+ }
+ }
+ }
+
+ /// Handle device announcement
+ async fn handle_device_announcement(&self, device_info: DeviceInfo) -> Result<()> {
+ info!("Device announced: {}", device_info.config.name);
+
+ let mut devices = self.known_devices.write().await;
+ devices.insert(device_info.address.clone(), device_info);
+
+ Ok(())
+ }
+
+ /// Handle discovery request
+ async fn handle_discovery_request(
+ &self,
+ requester: BusAddress,
+ filter: Option,
+ ) -> Result<()> {
+ debug!("Discovery request from: {}", requester.name);
+
+ let devices = self.known_devices.read().await;
+ let mut matching_devices = vec![self.local_device.clone()]; // Include self
+
+ // Add matching known devices
+ for device in devices.values() {
+ if let Some(ref filter) = filter {
+ if filter.matches(device) {
+ matching_devices.push(device.clone());
+ }
+ } else {
+ matching_devices.push(device.clone());
+ }
+ }
+
+ drop(devices); // Release the lock
+
+ let response = DiscoveryMessage::DiscoverResponse {
+ devices: matching_devices,
+ responder: self.local_device.address.clone(),
+ timestamp: SystemTime::now(),
+ };
+
+ self.send_discovery_message(response).await
+ }
+
+ /// Handle discovery response
+ async fn handle_discovery_response(&self, devices: Vec) -> Result<()> {
+ debug!("Received discovery response with {} devices", devices.len());
+
+ let mut known_devices = self.known_devices.write().await;
+ for device in devices {
+ // Don't add ourselves
+ if device.address != self.local_device.address {
+ known_devices.insert(device.address.clone(), device);
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Handle heartbeat message
+ async fn handle_heartbeat(&self, device: BusAddress, timestamp: SystemTime) -> Result<()> {
+ debug!("Heartbeat from device: {}", device.name);
+
+ let mut devices = self.known_devices.write().await;
+ if let Some(device_info) = devices.get_mut(&device) {
+ device_info.last_seen = timestamp;
+ }
+
+ Ok(())
+ }
+
+ /// Handle goodbye message
+ async fn handle_goodbye(&self, device: BusAddress) -> Result<()> {
+ info!("Device going offline: {}", device.name);
+
+ let mut devices = self.known_devices.write().await;
+ devices.remove(&device);
+
+ Ok(())
+ }
+
+ /// Clean up expired devices
+ pub async fn cleanup_expired_devices(&self) -> Result<()> {
+ let now = SystemTime::now();
+ let timeout = self.config.device_timeout;
+
+ let mut devices = self.known_devices.write().await;
+ let mut expired_devices = Vec::new();
+
+ for (address, device_info) in devices.iter() {
+ if let Ok(elapsed) = now.duration_since(device_info.last_seen) {
+ if elapsed > timeout {
+ expired_devices.push(address.clone());
+ }
+ }
+ }
+
+ for address in expired_devices {
+ warn!("Removing expired device: {}", address.name);
+ devices.remove(&address);
+ }
+
+ Ok(())
+ }
+
+ /// Send discovery message over the bus
+ async fn send_discovery_message(&self, discovery_msg: DiscoveryMessage) -> Result<()> {
+ if let Some(sender) = &self.message_sender {
+ let payload = serde_json::to_vec(&discovery_msg)?;
+
+ let bus_message = BusMessage::Broadcast {
+ from: self.local_device.address.clone(),
+ payload,
+ message_id: Uuid::new_v4(),
+ };
+
+ sender.send(bus_message).map_err(|_| {
+ HardwareError::bus_communication("Failed to send discovery message")
+ })?;
+ } else {
+ return Err(HardwareError::generic("Discovery protocol not connected to bus"));
+ }
+
+ Ok(())
+ }
+
+ /// Run the discovery protocol main loop
+ pub async fn run(&mut self) -> Result<()> {
+ let mut heartbeat_timer = tokio::time::interval(self.config.heartbeat_interval);
+ let mut cleanup_timer = tokio::time::interval(self.config.cleanup_interval);
+
+ while self.is_running {
+ tokio::select! {
+ _ = heartbeat_timer.tick() => {
+ if let Err(e) = self.send_heartbeat().await {
+ warn!("Failed to send heartbeat: {}", e);
+ }
+ }
+ _ = cleanup_timer.tick() => {
+ if let Err(e) = self.cleanup_expired_devices().await {
+ warn!("Failed to cleanup expired devices: {}", e);
+ }
+ }
+ // Handle incoming discovery messages if receiver is set
+ msg = async {
+ if let Some(ref mut receiver) = self.discovery_receiver {
+ receiver.recv().await
+ } else {
+ std::future::pending().await
+ }
+ } => {
+ if let Some(discovery_msg) = msg {
+ if let Err(e) = self.handle_discovery_message(discovery_msg).await {
+ warn!("Failed to handle discovery message: {}", e);
+ }
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Check if the protocol is running
+ pub fn is_running(&self) -> bool {
+ self.is_running
+ }
+
+ /// Get the number of known devices
+ pub async fn device_count(&self) -> usize {
+ let devices = self.known_devices.read().await;
+ devices.len()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{DeviceConfig, DeviceStatus};
+
+ fn create_test_device_info(name: &str) -> DeviceInfo {
+ DeviceInfo {
+ address: BusAddress::new(name),
+ config: DeviceConfig {
+ name: name.to_string(),
+ capabilities: vec![DeviceCapability::Gps],
+ ..Default::default()
+ },
+ status: DeviceStatus::Online,
+ last_seen: SystemTime::now(),
+ version: "1.0.0".to_string(),
+ manufacturer: "Test Manufacturer".to_string(),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_discovery_protocol_creation() {
+ let device_info = create_test_device_info("test_device");
+ let config = DiscoveryConfig::default();
+ let protocol = DiscoveryProtocol::new(device_info, config);
+
+ assert!(!protocol.is_running());
+ assert_eq!(protocol.device_count().await, 0);
+ }
+
+ #[tokio::test]
+ async fn test_device_announcement() {
+ let device_info = create_test_device_info("test_device");
+ let config = DiscoveryConfig::default();
+ let protocol = DiscoveryProtocol::new(device_info.clone(), config);
+
+ let other_device = create_test_device_info("other_device");
+ protocol.handle_device_announcement(other_device.clone()).await.unwrap();
+
+ let known_devices = protocol.get_known_devices().await;
+ assert_eq!(known_devices.len(), 1);
+ assert_eq!(known_devices[0].config.name, "other_device");
+ }
+
+ #[tokio::test]
+ async fn test_discovery_filter() {
+ let filter = DiscoveryFilter::new()
+ .with_capabilities(vec![DeviceCapability::Gps])
+ .with_name_pattern("test");
+
+ let device_info = create_test_device_info("test_device");
+ assert!(filter.matches(&device_info));
+
+ let other_device = DeviceInfo {
+ address: BusAddress::new("other"),
+ config: DeviceConfig {
+ name: "other".to_string(),
+ capabilities: vec![DeviceCapability::Radar],
+ ..Default::default()
+ },
+ status: DeviceStatus::Online,
+ last_seen: SystemTime::now(),
+ version: "1.0.0".to_string(),
+ manufacturer: "Test".to_string(),
+ };
+ assert!(!filter.matches(&other_device));
+ }
+
+ #[tokio::test]
+ async fn test_device_cleanup() {
+ let device_info = create_test_device_info("test_device");
+ let mut config = DiscoveryConfig::default();
+ config.device_timeout = Duration::from_millis(100);
+
+ let protocol = DiscoveryProtocol::new(device_info, config);
+
+ // Add a device with old timestamp
+ let mut old_device = create_test_device_info("old_device");
+ old_device.last_seen = SystemTime::now() - Duration::from_secs(200);
+
+ protocol.handle_device_announcement(old_device).await.unwrap();
+ assert_eq!(protocol.device_count().await, 1);
+
+ // Wait and cleanup
+ tokio::time::sleep(Duration::from_millis(150)).await;
+ protocol.cleanup_expired_devices().await.unwrap();
+
+ assert_eq!(protocol.device_count().await, 0);
+ }
+}
\ No newline at end of file
diff --git a/crates/hardware/src/error.rs b/crates/hardware/src/error.rs
new file mode 100644
index 0000000..50eb773
--- /dev/null
+++ b/crates/hardware/src/error.rs
@@ -0,0 +1,72 @@
+//! Error types for the hardware abstraction layer
+
+use thiserror::Error;
+
+/// Result type alias for hardware operations
+pub type Result = std::result::Result;
+
+/// Common error types for hardware operations
+#[derive(Error, Debug)]
+pub enum HardwareError {
+ /// Device not found on the bus
+ #[error("Device not found: {device_id}")]
+ DeviceNotFound { device_id: String },
+
+ /// Bus communication error
+ #[error("Bus communication error: {message}")]
+ BusCommunicationError { message: String },
+
+ /// Device is not responding
+ #[error("Device not responding: {device_id}")]
+ DeviceNotResponding { device_id: String },
+
+ /// Invalid device capability
+ #[error("Invalid device capability: {capability}")]
+ InvalidCapability { capability: String },
+
+ /// Discovery protocol error
+ #[error("Discovery protocol error: {message}")]
+ DiscoveryError { message: String },
+
+ /// Device initialization error
+ #[error("Device initialization failed: {device_id}, reason: {reason}")]
+ InitializationError { device_id: String, reason: String },
+
+ /// Serialization/Deserialization error
+ #[error("Serialization error: {0}")]
+ SerializationError(#[from] serde_json::Error),
+
+ /// Generic hardware error
+ #[error("Hardware error: {message}")]
+ Generic { message: String },
+}
+
+impl HardwareError {
+ /// Create a new generic hardware error
+ pub fn generic(message: impl Into) -> Self {
+ Self::Generic {
+ message: message.into(),
+ }
+ }
+
+ /// Create a new bus communication error
+ pub fn bus_communication(message: impl Into) -> Self {
+ Self::BusCommunicationError {
+ message: message.into(),
+ }
+ }
+
+ /// Create a new device not found error
+ pub fn device_not_found(device_id: impl Into) -> Self {
+ Self::DeviceNotFound {
+ device_id: device_id.into(),
+ }
+ }
+
+ /// Create a new discovery error
+ pub fn discovery_error(message: impl Into) -> Self {
+ Self::DiscoveryError {
+ message: message.into(),
+ }
+ }
+}
diff --git a/crates/hardware/src/gps_device.rs b/crates/hardware/src/gps_device.rs
new file mode 100644
index 0000000..b2578a6
--- /dev/null
+++ b/crates/hardware/src/gps_device.rs
@@ -0,0 +1,534 @@
+//! 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,
+ pub longitude: Option,
+ pub altitude: Option,
+ pub speed: Option,
+ pub timestamp: Option,
+ pub fix_quality: Option,
+ pub satellites: Option,
+}
+
+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 {
+ 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 {
+ 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::() {
+ 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::() {
+ 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::() {
+ location.fix_quality = Some(quality);
+ }
+ }
+
+ // Parse number of satellites (field 7)
+ if !parts[7].is_empty() {
+ if let Ok(sats) = parts[7].parse::() {
+ location.satellites = Some(sats);
+ }
+ }
+
+ // Parse altitude (field 9)
+ if !parts[9].is_empty() {
+ if let Ok(alt) = parts[9].parse::() {
+ location.altitude = Some(alt);
+ }
+ }
+
+ Some(location)
+ }
+
+ fn parse_gprmc(&self, parts: &[&str]) -> Option {
+ 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::() {
+ 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::() {
+ 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::() {
+ location.speed = Some(speed_knots);
+ }
+ }
+
+ Some(location)
+ }
+}
+
+/// GPS Device configuration
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GpsDeviceConfig {
+ pub device_paths: Vec,
+ 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,
+ serial_port: Option>>>,
+ 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> {
+ 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> {
+ 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> {
+ 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 {
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/crates/hardware/src/lib.rs b/crates/hardware/src/lib.rs
new file mode 100644
index 0000000..854eaf0
--- /dev/null
+++ b/crates/hardware/src/lib.rs
@@ -0,0 +1,27 @@
+//! Virtual Hardware Abstraction Layer
+//!
+//! This crate provides a common abstraction for virtual hardware components
+//! including a hardware bus, system devices, and discovery protocols.
+
+#![allow(clippy::type_complexity)]
+
+pub mod bus;
+pub mod device;
+pub mod discovery_protocol;
+pub mod error;
+
+// Re-export main types
+pub use bus::{HardwareBus, BusMessage, BusAddress};
+pub use device::{SystemDevice, DeviceCapability, DeviceStatus, DeviceInfo, DeviceConfig};
+pub use discovery_protocol::{DiscoveryProtocol, DiscoveryMessage};
+pub use error::{HardwareError, Result};
+
+/// Common traits and types used throughout the hardware abstraction layer
+pub mod prelude {
+ pub use crate::{
+ HardwareBus, BusMessage, BusAddress,
+ SystemDevice, DeviceCapability, DeviceStatus, DeviceInfo, DeviceConfig,
+ DiscoveryProtocol, DiscoveryMessage,
+ HardwareError, Result,
+ };
+}
diff --git a/crates/systems/src/lib.rs b/crates/systems/src/lib.rs
index 73419fe..1a2323e 100644
--- a/crates/systems/src/lib.rs
+++ b/crates/systems/src/lib.rs
@@ -9,7 +9,7 @@ mod geo_plugin;
// Re-export components from the components crate
pub use components::{
- setup_instrument_cluster, update_instrument_displays, update_vessel_data, VesselData,
+ setup_instrument_cluster, update_instrument_displays, update_vessel_data, update_vessel_data_with_gps, VesselData,
SpeedGauge, DepthGauge, CompassGauge, EngineStatus, NavigationDisplay,
InstrumentCluster, GpsIndicator, RadarIndicator, AisIndicator, SystemDisplay
};
diff --git a/crates/systems/src/vessel/vessel_systems.rs b/crates/systems/src/vessel/vessel_systems.rs
index acedd4b..df0cb9b 100644
--- a/crates/systems/src/vessel/vessel_systems.rs
+++ b/crates/systems/src/vessel/vessel_systems.rs
@@ -62,8 +62,8 @@ mod tests {
assert_eq!(gps.display_name(), "GPS Navigation");
assert_eq!(gps.status(), SystemStatus::Active);
- let vessle_data = VesselData::default();
- let display = gps.render_display(&vessle_data);
+ let vessel_data = VesselData::default();
+ let display = gps.render_display(&vessel_data);
assert!(display.contains("GPS NAVIGATION SYSTEM"));
assert!(display.contains("Satellites: 12 connected"));
}
diff --git a/crates/yachtpit/Cargo.toml b/crates/yachtpit/Cargo.toml
index 34fac7a..9b94779 100644
--- a/crates/yachtpit/Cargo.toml
+++ b/crates/yachtpit/Cargo.toml
@@ -90,7 +90,9 @@ bevy_webview_wry = { version = "0.4", default-features = false, features = ["api
bevy_flurx = "0.11"
bevy_flurx_ipc = "0.4.0"
# (run `cargo tree | grep wry` and use the version you see for bevy_webview_wry)
-wry = { version = "=0.51.2", optional = true, features = ["os-webview"] } # GPS support for native platforms - placeholder for future real GPS implementation
+wry = { version = "=0.51.2", optional = true, features = ["os-webview"] }
+# GPS support for native platforms using GPYes device
+serialport = "4.2"
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1.0", features = ["rt"] }
@@ -98,3 +100,4 @@ console_error_panic_hook = "0.1"
[build-dependencies]
embed-resource = "1"
+base-map = { path = "../base-map" }
diff --git a/crates/yachtpit/src/lib.rs b/crates/yachtpit/src/lib.rs
index 3720eb3..f08c214 100644
--- a/crates/yachtpit/src/lib.rs
+++ b/crates/yachtpit/src/lib.rs
@@ -13,7 +13,8 @@ use crate::core::{ActionsPlugin, SystemManagerPlugin};
use crate::core::system_manager::SystemManager;
use crate::ui::{LoadingPlugin, MenuPlugin, GpsMapPlugin};
use crate::services::GpsServicePlugin;
-use systems::{PlayerPlugin, setup_instrument_cluster, get_vessel_systems};
+use systems::{PlayerPlugin, setup_instrument_cluster, get_vessel_systems, CompassGauge, SpeedGauge, VesselData, update_vessel_data_with_gps};
+use crate::ui::GpsMapState;
#[cfg(target_arch = "wasm32")]
use systems::GeoPlugin;
@@ -39,6 +40,42 @@ fn initialize_vessel_systems(mut system_manager: ResMut) {
}
}
+/// Update compass gauge with real GPS heading data
+fn update_compass_heading(
+ gps_map_state: Res,
+ mut compass_query: Query<&mut Text, With>,
+) {
+ for mut text in compass_query.iter_mut() {
+ // Update compass display with real GPS heading
+ text.0 = format!("{:03.0}°", gps_map_state.vessel_heading);
+ }
+}
+
+/// Update speed gauge with real GPS speed data
+fn update_speed_gauge(
+ gps_map_state: Res,
+ mut speed_query: Query<&mut Text, With>,
+) {
+ for mut text in speed_query.iter_mut() {
+ // Update speed display with real GPS speed data
+ // Only update if the text contains a decimal point (to avoid updating labels)
+ if text.0.contains('.') {
+ text.0 = format!("{:.1}", gps_map_state.vessel_speed);
+ }
+ }
+}
+
+/// Update vessel data with real GPS data for consistent system displays
+fn update_vessel_data_with_real_gps(
+ gps_map_state: Res,
+ vessel_data: ResMut,
+ time: Res,
+) {
+ // Use real GPS data from GpsMapState
+ let gps_data = Some((gps_map_state.vessel_speed, gps_map_state.vessel_heading));
+ update_vessel_data_with_gps(vessel_data, time, gps_data);
+}
+
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
app.init_state::().add_plugins((
@@ -51,7 +88,12 @@ impl Plugin for GamePlugin {
PlayerPlugin,
))
- .add_systems(OnEnter(GameState::Playing), (setup_instrument_cluster, initialize_vessel_systems));
+ .add_systems(OnEnter(GameState::Playing), (setup_instrument_cluster, initialize_vessel_systems))
+ .add_systems(Update, (
+ update_compass_heading,
+ update_speed_gauge,
+ update_vessel_data_with_real_gps,
+ ).run_if(in_state(GameState::Playing)));
#[cfg(target_arch = "wasm32")]
{
diff --git a/crates/yachtpit/src/services/debug_service.rs b/crates/yachtpit/src/services/debug_service.rs
new file mode 100644
index 0000000..6dca8a3
--- /dev/null
+++ b/crates/yachtpit/src/services/debug_service.rs
@@ -0,0 +1,366 @@
+use bevy::prelude::*;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use tracing::{debug, error, info, trace, warn};
+use sysinfo::System;
+
+/// Debug levels for controlling verbosity
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum DebugLevel {
+ Trace,
+ Debug,
+ Info,
+ Warn,
+ Error,
+}
+
+impl From for tracing::Level {
+ fn from(level: DebugLevel) -> Self {
+ match level {
+ DebugLevel::Trace => tracing::Level::TRACE,
+ DebugLevel::Debug => tracing::Level::DEBUG,
+ DebugLevel::Info => tracing::Level::INFO,
+ DebugLevel::Warn => tracing::Level::WARN,
+ DebugLevel::Error => tracing::Level::ERROR,
+ }
+ }
+}
+
+/// Performance metrics for monitoring system performance
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PerformanceMetrics {
+ pub cpu_usage: f32,
+ pub memory_usage: u64,
+ pub memory_total: u64,
+ pub fps: f32,
+ pub frame_time_ms: f32,
+ pub timestamp: f64,
+}
+
+/// Debug configuration that can be controlled via environment variables
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DebugConfig {
+ pub enabled: bool,
+ pub level: DebugLevel,
+ pub log_to_file: bool,
+ pub log_file_path: String,
+ pub performance_monitoring: bool,
+ pub gps_debug: bool,
+ pub ui_debug: bool,
+ pub network_debug: bool,
+ pub detailed_logging: bool,
+}
+
+impl Default for DebugConfig {
+ fn default() -> Self {
+ Self {
+ enabled: std::env::var("YACHTPIT_DEBUG").unwrap_or_default() == "true",
+ level: match std::env::var("YACHTPIT_DEBUG_LEVEL").unwrap_or_default().as_str() {
+ "trace" => DebugLevel::Trace,
+ "debug" => DebugLevel::Debug,
+ "warn" => DebugLevel::Warn,
+ "error" => DebugLevel::Error,
+ _ => DebugLevel::Info,
+ },
+ log_to_file: std::env::var("YACHTPIT_LOG_FILE").unwrap_or_default() == "true",
+ log_file_path: std::env::var("YACHTPIT_LOG_PATH").unwrap_or_else(|_| "yachtpit_debug.log".to_string()),
+ performance_monitoring: std::env::var("YACHTPIT_PERF_MONITOR").unwrap_or_default() == "true",
+ gps_debug: std::env::var("YACHTPIT_GPS_DEBUG").unwrap_or_default() == "true",
+ ui_debug: std::env::var("YACHTPIT_UI_DEBUG").unwrap_or_default() == "true",
+ network_debug: std::env::var("YACHTPIT_NETWORK_DEBUG").unwrap_or_default() == "true",
+ detailed_logging: std::env::var("YACHTPIT_DETAILED_LOG").unwrap_or_default() == "true",
+ }
+ }
+}
+
+/// Debug service resource for managing debugging capabilities
+#[derive(Resource)]
+pub struct DebugService {
+ pub config: DebugConfig,
+ pub performance_metrics: Option,
+ pub debug_data: HashMap,
+ pub system_info: System,
+ pub start_time: Instant,
+ pub last_perf_update: Instant,
+}
+
+impl Default for DebugService {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl DebugService {
+ pub fn new() -> Self {
+ let config = DebugConfig::default();
+
+ // Initialize tracing subscriber if debug is enabled
+ if config.enabled {
+ Self::init_tracing(&config);
+ }
+
+ Self {
+ config,
+ performance_metrics: None,
+ debug_data: HashMap::new(),
+ system_info: System::new_all(),
+ start_time: Instant::now(),
+ last_perf_update: Instant::now(),
+ }
+ }
+
+ /// Initialize tracing subscriber with appropriate configuration
+ fn init_tracing(config: &DebugConfig) {
+ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
+
+ let env_filter = EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| {
+ let level = match config.level {
+ DebugLevel::Trace => "trace",
+ DebugLevel::Debug => "debug",
+ DebugLevel::Info => "info",
+ DebugLevel::Warn => "warn",
+ DebugLevel::Error => "error",
+ };
+ EnvFilter::new(format!("yachtpit={}", level))
+ });
+
+ let subscriber = tracing_subscriber::registry()
+ .with(env_filter)
+ .with(tracing_subscriber::fmt::layer()
+ .with_target(true)
+ .with_thread_ids(true)
+ .with_file(config.detailed_logging)
+ .with_line_number(config.detailed_logging));
+
+ if config.log_to_file {
+ let file_appender = tracing_appender::rolling::daily("logs", &config.log_file_path);
+ let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
+
+ subscriber
+ .with(tracing_subscriber::fmt::layer()
+ .with_writer(non_blocking)
+ .with_ansi(false))
+ .init();
+ } else {
+ subscriber.init();
+ }
+
+ info!("Debug service initialized with level: {:?}", config.level);
+ }
+
+ /// Log debug information with context
+ pub fn debug_log(&self, component: &str, message: &str, data: Option) {
+ if !self.config.enabled {
+ return;
+ }
+
+ match data {
+ Some(data) => debug!(component = component, data = ?data, "{}", message),
+ None => debug!(component = component, "{}", message),
+ }
+ }
+
+ /// Log GPS-specific debug information
+ pub fn debug_gps(&self, message: &str, lat: f64, lon: f64, heading: Option, speed: Option) {
+ if !self.config.enabled || !self.config.gps_debug {
+ return;
+ }
+
+ debug!(
+ component = "GPS",
+ latitude = lat,
+ longitude = lon,
+ heading = heading,
+ speed = speed,
+ "{}",
+ message
+ );
+ }
+
+ /// Log performance metrics
+ pub fn debug_performance(&mut self, fps: f32, frame_time_ms: f32) {
+ if !self.config.enabled || !self.config.performance_monitoring {
+ return;
+ }
+
+ // Update performance metrics every 5 seconds
+ if self.last_perf_update.elapsed() >= Duration::from_secs(5) {
+ self.system_info.refresh_all();
+
+ let cpu_usage = self.system_info.global_cpu_info().cpu_usage();
+ let memory_usage = self.system_info.used_memory();
+ let memory_total = self.system_info.total_memory();
+
+ let metrics = PerformanceMetrics {
+ cpu_usage,
+ memory_usage,
+ memory_total,
+ fps,
+ frame_time_ms,
+ timestamp: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs_f64(),
+ };
+
+ debug!(
+ component = "PERFORMANCE",
+ cpu_usage = cpu_usage,
+ memory_usage = memory_usage,
+ memory_total = memory_total,
+ fps = fps,
+ frame_time_ms = frame_time_ms,
+ "Performance metrics updated"
+ );
+
+ self.performance_metrics = Some(metrics);
+ self.last_perf_update = Instant::now();
+ }
+ }
+
+ /// Store arbitrary debug data
+ pub fn store_debug_data(&mut self, key: String, value: serde_json::Value) {
+ if self.config.enabled {
+ self.debug_data.insert(key, value);
+ }
+ }
+
+ /// Get stored debug data
+ pub fn get_debug_data(&self, key: &str) -> Option<&serde_json::Value> {
+ self.debug_data.get(key)
+ }
+
+ /// Get all debug data as JSON
+ pub fn get_all_debug_data(&self) -> serde_json::Value {
+ serde_json::json!({
+ "config": self.config,
+ "performance_metrics": self.performance_metrics,
+ "debug_data": self.debug_data,
+ "uptime_seconds": self.start_time.elapsed().as_secs(),
+ })
+ }
+
+ /// Log system information
+ pub fn log_system_info(&mut self) {
+ if !self.config.enabled {
+ return;
+ }
+
+ self.system_info.refresh_all();
+
+ info!(
+ component = "SYSTEM",
+ os = System::name().unwrap_or_default(),
+ kernel_version = System::kernel_version().unwrap_or_default(),
+ cpu_count = self.system_info.cpus().len(),
+ total_memory = self.system_info.total_memory(),
+ "System information"
+ );
+ }
+
+ /// Create a debug snapshot of current state
+ pub fn create_debug_snapshot(&mut self) -> serde_json::Value {
+ if !self.config.enabled {
+ return serde_json::json!({});
+ }
+
+ self.system_info.refresh_all();
+
+ serde_json::json!({
+ "timestamp": SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs_f64(),
+ "uptime_seconds": self.start_time.elapsed().as_secs(),
+ "config": self.config,
+ "performance": self.performance_metrics,
+ "system": {
+ "cpu_usage": self.system_info.global_cpu_info().cpu_usage(),
+ "memory_usage": self.system_info.used_memory(),
+ "memory_total": self.system_info.total_memory(),
+ "process_count": self.system_info.processes().len(),
+ },
+ "debug_data": self.debug_data,
+ })
+ }
+
+ /// Enable/disable debug mode at runtime
+ pub fn set_debug_enabled(&mut self, enabled: bool) {
+ self.config.enabled = enabled;
+ if enabled {
+ info!("Debug mode enabled at runtime");
+ } else {
+ info!("Debug mode disabled at runtime");
+ }
+ }
+
+ /// Change debug level at runtime
+ pub fn set_debug_level(&mut self, level: DebugLevel) {
+ self.config.level = level;
+ info!("Debug level changed to: {:?}", level);
+ }
+}
+
+/// System for updating debug service
+pub fn update_debug_service(
+ mut debug_service: ResMut,
+ diagnostics: Res,
+) {
+ if !debug_service.config.enabled {
+ return;
+ }
+
+ // Get FPS and frame time from Bevy diagnostics
+ let fps = diagnostics
+ .get(&bevy::diagnostic::FrameTimeDiagnosticsPlugin::FPS)
+ .and_then(|fps| fps.smoothed())
+ .unwrap_or(0.0) as f32;
+
+ let frame_time = diagnostics
+ .get(&bevy::diagnostic::FrameTimeDiagnosticsPlugin::FRAME_TIME)
+ .and_then(|frame_time| frame_time.smoothed())
+ .unwrap_or(0.0) as f32 * 1000.0; // Convert to milliseconds
+
+ debug_service.debug_performance(fps, frame_time);
+}
+
+/// Debug service plugin
+pub struct DebugServicePlugin;
+
+impl Plugin for DebugServicePlugin {
+ fn build(&self, app: &mut App) {
+ let debug_service = DebugService::new();
+
+ // Log system info on startup if debug is enabled
+ if debug_service.config.enabled {
+ info!("YachtPit Debug Service starting up...");
+ }
+
+ app.insert_resource(debug_service)
+ .add_systems(Update, update_debug_service);
+ }
+}
+
+/// Macro for easy debug logging
+#[macro_export]
+macro_rules! debug_log {
+ ($debug_service:expr, $component:expr, $message:expr) => {
+ $debug_service.debug_log($component, $message, None);
+ };
+ ($debug_service:expr, $component:expr, $message:expr, $data:expr) => {
+ $debug_service.debug_log($component, $message, Some($data));
+ };
+}
+
+/// Macro for GPS debug logging
+#[macro_export]
+macro_rules! debug_gps {
+ ($debug_service:expr, $message:expr, $lat:expr, $lon:expr) => {
+ $debug_service.debug_gps($message, $lat, $lon, None, None);
+ };
+ ($debug_service:expr, $message:expr, $lat:expr, $lon:expr, $heading:expr, $speed:expr) => {
+ $debug_service.debug_gps($message, $lat, $lon, $heading, $speed);
+ };
+}
\ No newline at end of file
diff --git a/crates/yachtpit/src/services/gps_service.rs b/crates/yachtpit/src/services/gps_service.rs
index a7b78e5..fc6a5ce 100644
--- a/crates/yachtpit/src/services/gps_service.rs
+++ b/crates/yachtpit/src/services/gps_service.rs
@@ -1,6 +1,11 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
+#[cfg(not(target_arch = "wasm32"))]
+use crate::services::gpyes_provider::GpyesProvider;
+#[cfg(not(target_arch = "wasm32"))]
+use tokio::sync::mpsc;
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpsData {
pub latitude: f64,
@@ -12,36 +17,80 @@ pub struct GpsData {
pub timestamp: f64,
}
-#[derive(Resource, Default)]
+#[derive(Resource)]
pub struct GpsService {
pub current_position: Option,
pub is_enabled: bool,
pub last_update: f64,
+ #[cfg(not(target_arch = "wasm32"))]
+ pub gpyes_provider: Option,
+ #[cfg(not(target_arch = "wasm32"))]
+ pub gps_receiver: Option>,
+}
+
+impl Default for GpsService {
+ fn default() -> Self {
+ Self::new()
+ }
}
impl GpsService {
pub fn new() -> Self {
- Self {
+ GpsService {
current_position: None,
is_enabled: false,
last_update: 0.0,
+ #[cfg(not(target_arch = "wasm32"))]
+ gpyes_provider: None,
+ #[cfg(not(target_arch = "wasm32"))]
+ gps_receiver: None,
}
}
pub fn enable(&mut self) {
self.is_enabled = true;
info!("GPS service enabled");
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ // Initialize GPYes provider if not already done
+ if self.gpyes_provider.is_none() {
+ let provider = GpyesProvider::new();
+ match provider.start_streaming() {
+ Ok(receiver) => {
+ info!("GPYes provider started successfully");
+ self.gps_receiver = Some(receiver);
+ self.gpyes_provider = Some(provider);
+ }
+ Err(e) => {
+ error!("Failed to start GPYes provider: {}", e);
+ // Fall back to mock data if hardware fails
+ }
+ }
+ }
+ }
}
pub fn disable(&mut self) {
self.is_enabled = false;
info!("GPS service disabled");
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ // Stop GPYes provider if running
+ if let Some(provider) = &self.gpyes_provider {
+ provider.stop_streaming();
+ }
+ self.gpyes_provider = None;
+ self.gps_receiver = None;
+ }
}
pub fn update_position(&mut self, gps_data: GpsData) {
self.current_position = Some(gps_data.clone());
self.last_update = gps_data.timestamp;
- info!("GPS position updated: lat={:.6}, lon={:.6}", gps_data.latitude, gps_data.longitude);
+ debug!("GPS position updated: lat={:.6}, lon={:.6}, heading={:?}",
+ gps_data.latitude, gps_data.longitude, gps_data.heading);
}
pub fn get_current_position(&self) -> Option<&GpsData> {
@@ -49,8 +98,7 @@ impl GpsService {
}
}
-// Native GPS implementation - Mock implementation for demonstration
-// TODO: Replace with real GPS hardware access (e.g., using gpsd, CoreLocation, etc.)
+// Native GPS implementation using GPYes device
#[cfg(not(target_arch = "wasm32"))]
pub fn start_native_gps_tracking(mut gps_service: ResMut, time: Res) {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -59,90 +107,69 @@ pub fn start_native_gps_tracking(mut gps_service: ResMut, time: Res<
return;
}
- // Mock GPS data that simulates realistic movement
- // In a real implementation, this would read from GPS hardware
+ // Try to receive GPS data from GPYes provider
+ if let Some(receiver) = &mut gps_service.gps_receiver {
+ match receiver.try_recv() {
+ Ok(gps_data) => {
+ gps_service.update_position(gps_data);
+ return;
+ }
+ Err(mpsc::error::TryRecvError::Empty) => {
+ // No new data available, continue with fallback
+ }
+ Err(mpsc::error::TryRecvError::Disconnected) => {
+ warn!("GPS receiver disconnected, clearing provider");
+ gps_service.gpyes_provider = None;
+ gps_service.gps_receiver = None;
+ }
+ }
+ }
+
+ // Fallback to mock data if no hardware provider is available
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64();
- // Only update every 2 seconds to simulate realistic GPS update rate
+ // Only update every 2 seconds to avoid spam
if timestamp - gps_service.last_update < 2.0 {
return;
}
+ warn!("Using mock GPS data - no hardware provider available");
+
// Simulate GPS coordinates around Monaco with realistic movement
let base_lat = 43.7384;
let base_lon = 7.4246;
- let time_factor = time.elapsed_secs() * 0.1;
-
- // Simulate a boat moving in a realistic pattern
- let lat_offset = (time_factor.sin() * 0.002) as f64;
- let lon_offset = (time_factor.cos() * 0.003) as f64;
-
- let gps_data = GpsData {
- latitude: base_lat + lat_offset,
- longitude: base_lon + lon_offset,
- altitude: Some(0.0), // Sea level
- accuracy: Some(3.0), // 3 meter accuracy
- heading: Some(((time_factor * 20.0) % 360.0) as f64),
- speed: Some(5.2), // 5.2 knots
+
+ // Create some movement pattern
+ let time_offset = (timestamp / 10.0).sin() * 0.001;
+ let lat_offset = (timestamp / 15.0).cos() * 0.0005;
+
+ let mock_gps_data = GpsData {
+ latitude: base_lat + time_offset,
+ longitude: base_lon + lat_offset,
+ altitude: Some(10.0 + (timestamp / 20.0).sin() * 5.0),
+ accuracy: Some(3.0),
+ heading: Some(((timestamp / 30.0) * 57.2958) % 360.0), // Convert to degrees
+ speed: Some(5.0 + (timestamp / 25.0).sin() * 2.0), // 3-7 knots
timestamp,
};
- gps_service.update_position(gps_data);
-}
-
-// Web GPS implementation using geolocation API
-// For web platforms, we'll use a simplified approach that requests position periodically
-#[cfg(target_arch = "wasm32")]
-pub fn start_web_gps_tracking(mut gps_service: ResMut, time: Res) {
- if !gps_service.is_enabled {
- return;
- }
-
- // Use Bevy's time instead of std::time for WASM compatibility
- let current_time = time.elapsed_secs_f64();
-
- // Only try to get GPS every 5 seconds to avoid overwhelming the browser
- if current_time - gps_service.last_update < 5.0 {
- return;
- }
-
- // For now, use mock data for web as well
- // TODO: Implement proper web geolocation API integration using channels or events
- let time_factor = time.elapsed_secs() * 0.1;
- let base_lat = 43.7384;
- let base_lon = 7.4246;
-
- let lat_offset = (time_factor.sin() * 0.001) as f64;
- let lon_offset = (time_factor.cos() * 0.002) as f64;
-
- let gps_data = GpsData {
- latitude: base_lat + lat_offset,
- longitude: base_lon + lon_offset,
- altitude: Some(0.0),
- accuracy: Some(5.0), // Slightly less accurate on web
- heading: Some(((time_factor * 15.0) % 360.0) as f64),
- speed: Some(4.8), // Slightly different speed for web
- timestamp: current_time,
- };
-
- gps_service.update_position(gps_data.clone());
- info!("Web GPS position updated: lat={:.6}, lon={:.6}", gps_data.latitude, gps_data.longitude);
+ gps_service.update_position(mock_gps_data);
}
+// Bevy plugin for GPS service
pub struct GpsServicePlugin;
impl Plugin for GpsServicePlugin {
fn build(&self, app: &mut App) {
- app.init_resource::()
- .add_systems(Update, (
- #[cfg(not(target_arch = "wasm32"))]
- start_native_gps_tracking,
- #[cfg(target_arch = "wasm32")]
- start_web_gps_tracking,
- ));
+ app.init_resource::();
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ app.add_systems(Update, start_native_gps_tracking);
+ }
}
}
@@ -150,88 +177,47 @@ impl Plugin for GpsServicePlugin {
mod tests {
use super::*;
- #[cfg(not(target_arch = "wasm32"))]
- use std::time::{SystemTime, UNIX_EPOCH};
-
#[test]
- fn test_gps_service_initialization() {
- let mut gps_service = GpsService::new();
- assert!(!gps_service.is_enabled);
- assert!(gps_service.current_position.is_none());
-
- gps_service.enable();
- assert!(gps_service.is_enabled);
- }
-
- #[test]
- #[cfg(not(target_arch = "wasm32"))]
- fn test_gps_data_update() {
- let mut gps_service = GpsService::new();
-
- let timestamp = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap()
- .as_secs_f64();
-
- let test_gps_data = GpsData {
- latitude: 43.7384,
- longitude: 7.4246,
- altitude: Some(0.0),
- accuracy: Some(3.0),
- heading: Some(45.0),
- speed: Some(5.2),
- timestamp,
- };
-
- gps_service.update_position(test_gps_data.clone());
-
- let current_pos = gps_service.get_current_position().unwrap();
- assert_eq!(current_pos.latitude, 43.7384);
- assert_eq!(current_pos.longitude, 7.4246);
- assert_eq!(current_pos.speed, Some(5.2));
- assert_eq!(current_pos.heading, Some(45.0));
- }
-
- #[test]
- #[cfg(target_arch = "wasm32")]
- fn test_gps_data_update_wasm() {
- let mut gps_service = GpsService::new();
-
- // Use a mock timestamp for WASM testing
- let timestamp = 1234567890.0;
-
- let test_gps_data = GpsData {
- latitude: 43.7384,
- longitude: 7.4246,
- altitude: Some(0.0),
- accuracy: Some(3.0),
- heading: Some(45.0),
- speed: Some(5.2),
- timestamp,
- };
-
- gps_service.update_position(test_gps_data.clone());
-
- let current_pos = gps_service.get_current_position().unwrap();
- assert_eq!(current_pos.latitude, 43.7384);
- assert_eq!(current_pos.longitude, 7.4246);
- assert_eq!(current_pos.speed, Some(5.2));
- assert_eq!(current_pos.heading, Some(45.0));
+ fn test_gps_service_creation() {
+ let service = GpsService::new();
+ assert!(!service.is_enabled);
+ assert!(service.current_position.is_none());
+ assert_eq!(service.last_update, 0.0);
}
#[test]
fn test_gps_service_enable_disable() {
- let mut gps_service = GpsService::new();
-
- // Test initial state
- assert!(!gps_service.is_enabled);
-
- // Test enable
- gps_service.enable();
- assert!(gps_service.is_enabled);
-
- // Test disable
- gps_service.disable();
- assert!(!gps_service.is_enabled);
+ let mut service = GpsService::new();
+
+ service.enable();
+ assert!(service.is_enabled);
+
+ service.disable();
+ assert!(!service.is_enabled);
}
-}
+
+ #[test]
+ fn test_gps_data_update() {
+ let mut service = GpsService::new();
+
+ let gps_data = GpsData {
+ latitude: 43.7384,
+ longitude: 7.4246,
+ altitude: Some(10.0),
+ accuracy: Some(3.0),
+ heading: Some(90.0),
+ speed: Some(5.0),
+ timestamp: 1234567890.0,
+ };
+
+ service.update_position(gps_data.clone());
+
+ assert!(service.current_position.is_some());
+ assert_eq!(service.last_update, 1234567890.0);
+
+ let position = service.get_current_position().unwrap();
+ assert_eq!(position.latitude, 43.7384);
+ assert_eq!(position.longitude, 7.4246);
+ assert_eq!(position.heading, Some(90.0));
+ }
+}
\ No newline at end of file
diff --git a/crates/yachtpit/src/services/gpyes_provider.rs b/crates/yachtpit/src/services/gpyes_provider.rs
new file mode 100644
index 0000000..caaac51
--- /dev/null
+++ b/crates/yachtpit/src/services/gpyes_provider.rs
@@ -0,0 +1,603 @@
+use std::sync::{Arc, Mutex};
+use std::thread;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+use std::io::{BufRead, BufReader, ErrorKind};
+use tokio::sync::mpsc;
+use log::{debug, error, info, warn};
+use serde::{Deserialize, Serialize};
+
+use super::gps_service::GpsData;
+
+/// Enhanced location data structure that includes heading and additional GPS metadata
+#[derive(Debug, Clone, PartialEq)]
+pub struct EnhancedLocationData {
+ pub latitude: Option,
+ pub longitude: Option,
+ pub altitude: Option,
+ pub speed: Option,
+ pub heading: Option, // Course over ground in degrees
+ pub timestamp: Option,
+ pub fix_quality: Option,
+ pub satellites: Option,
+ pub hdop: Option, // Horizontal dilution of precision
+}
+
+impl Default for EnhancedLocationData {
+ fn default() -> Self {
+ EnhancedLocationData {
+ latitude: None,
+ longitude: None,
+ altitude: None,
+ speed: None,
+ heading: None,
+ timestamp: None,
+ fix_quality: None,
+ satellites: None,
+ hdop: None,
+ }
+ }
+}
+
+impl From for GpsData {
+ fn from(enhanced: EnhancedLocationData) -> Self {
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs_f64();
+
+ GpsData {
+ latitude: enhanced.latitude.unwrap_or(0.0),
+ longitude: enhanced.longitude.unwrap_or(0.0),
+ altitude: enhanced.altitude,
+ accuracy: enhanced.hdop,
+ heading: enhanced.heading,
+ speed: enhanced.speed,
+ timestamp,
+ }
+ }
+}
+
+/// Enhanced GNSS parser that supports heading and additional GPS data
+pub struct EnhancedGnssParser {
+ debug_enabled: bool,
+}
+
+impl EnhancedGnssParser {
+ pub fn new() -> Self {
+ EnhancedGnssParser {
+ debug_enabled: std::env::var("GPS_DEBUG").is_ok(),
+ }
+ }
+
+ pub fn with_debug(mut self, enabled: bool) -> Self {
+ self.debug_enabled = enabled;
+ self
+ }
+
+ pub fn parse_sentence(&self, sentence: &str) -> Option {
+ if sentence.is_empty() || !sentence.starts_with('$') {
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Invalid sentence format: {}", sentence);
+ }
+ return None;
+ }
+
+ let parts: Vec<&str> = sentence.split(',').collect();
+ if parts.is_empty() {
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Empty sentence parts");
+ }
+ return None;
+ }
+
+ let sentence_type = parts[0];
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsing sentence type: {}", sentence_type);
+ }
+
+ match sentence_type {
+ "$GPGGA" | "$GNGGA" => self.parse_gpgga(&parts),
+ "$GPRMC" | "$GNRMC" => self.parse_gprmc(&parts),
+ "$GPVTG" | "$GNVTG" => self.parse_gpvtg(&parts), // Course and speed
+ _ => {
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Unsupported sentence type: {}", sentence_type);
+ }
+ None
+ }
+ }
+ }
+
+ fn parse_gpgga(&self, parts: &[&str]) -> Option {
+ if parts.len() < 15 {
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] GPGGA sentence too short: {} parts", parts.len());
+ }
+ return None;
+ }
+
+ let mut location = EnhancedLocationData::default();
+
+ // Parse timestamp (field 1)
+ if !parts[1].is_empty() {
+ location.timestamp = Some(parts[1].to_string());
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed timestamp: {}", parts[1]);
+ }
+ }
+
+ // Parse latitude (fields 2 and 3)
+ if !parts[2].is_empty() && !parts[3].is_empty() {
+ if let Ok(lat_raw) = parts[2].parse::() {
+ 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);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed latitude: {:.6}°", latitude);
+ }
+ }
+ }
+
+ // Parse longitude (fields 4 and 5)
+ if !parts[4].is_empty() && !parts[5].is_empty() {
+ if let Ok(lon_raw) = parts[4].parse::() {
+ 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);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed longitude: {:.6}°", longitude);
+ }
+ }
+ }
+
+ // Parse fix quality (field 6)
+ if !parts[6].is_empty() {
+ if let Ok(quality) = parts[6].parse::() {
+ location.fix_quality = Some(quality);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed fix quality: {}", quality);
+ }
+ }
+ }
+
+ // Parse number of satellites (field 7)
+ if !parts[7].is_empty() {
+ if let Ok(sats) = parts[7].parse::() {
+ location.satellites = Some(sats);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed satellites: {}", sats);
+ }
+ }
+ }
+
+ // Parse HDOP (field 8)
+ if !parts[8].is_empty() {
+ if let Ok(hdop) = parts[8].parse::() {
+ location.hdop = Some(hdop);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed HDOP: {:.2}", hdop);
+ }
+ }
+ }
+
+ // Parse altitude (field 9)
+ if !parts[9].is_empty() {
+ if let Ok(alt) = parts[9].parse::() {
+ location.altitude = Some(alt);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed altitude: {:.1} m", alt);
+ }
+ }
+ }
+
+ Some(location)
+ }
+
+ fn parse_gprmc(&self, parts: &[&str]) -> Option {
+ if parts.len() < 12 {
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] GPRMC sentence too short: {} parts", parts.len());
+ }
+ return None;
+ }
+
+ let mut location = EnhancedLocationData::default();
+
+ // Parse timestamp (field 1)
+ if !parts[1].is_empty() {
+ location.timestamp = Some(parts[1].to_string());
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed timestamp: {}", parts[1]);
+ }
+ }
+
+ // Check if data is valid (field 2)
+ if parts[2] != "A" {
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] GPRMC data invalid: {}", parts[2]);
+ }
+ 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::() {
+ 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);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed latitude: {:.6}°", latitude);
+ }
+ }
+ }
+
+ // Parse longitude (fields 5 and 6)
+ if !parts[5].is_empty() && !parts[6].is_empty() {
+ if let Ok(lon_raw) = parts[5].parse::() {
+ 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);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed longitude: {:.6}°", longitude);
+ }
+ }
+ }
+
+ // Parse speed (field 7) - in knots
+ if !parts[7].is_empty() {
+ if let Ok(speed_knots) = parts[7].parse::() {
+ location.speed = Some(speed_knots);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed speed: {:.1} knots", speed_knots);
+ }
+ }
+ }
+
+ // Parse course/heading (field 8) - in degrees
+ if !parts[8].is_empty() {
+ if let Ok(course) = parts[8].parse::() {
+ location.heading = Some(course);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed course: {:.1}°", course);
+ }
+ }
+ }
+
+ Some(location)
+ }
+
+ fn parse_gpvtg(&self, parts: &[&str]) -> Option {
+ if parts.len() < 9 {
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] GPVTG sentence too short: {} parts", parts.len());
+ }
+ return None;
+ }
+
+ let mut location = EnhancedLocationData::default();
+
+ // Parse true course (field 1)
+ if !parts[1].is_empty() {
+ if let Ok(course) = parts[1].parse::() {
+ location.heading = Some(course);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed true course: {:.1}°", course);
+ }
+ }
+ }
+
+ // Parse speed in knots (field 5)
+ if !parts[5].is_empty() {
+ if let Ok(speed_knots) = parts[5].parse::() {
+ location.speed = Some(speed_knots);
+ if self.debug_enabled {
+ debug!("[GPS_DEBUG] Parsed speed: {:.1} knots", speed_knots);
+ }
+ }
+ }
+
+ Some(location)
+ }
+}
+
+/// GPYes device provider that abstracts the GPS hardware
+pub struct GpyesProvider {
+ device_paths: Vec,
+ baud_rate: u32,
+ parser: EnhancedGnssParser,
+ is_running: Arc>,
+ debug_enabled: bool,
+}
+
+impl GpyesProvider {
+ pub fn new() -> Self {
+ let debug_enabled = std::env::var("GPS_DEBUG").is_ok();
+
+ GpyesProvider {
+ device_paths: vec![
+ "/dev/tty.usbmodem2101".to_string(),
+ "/dev/cu.usbmodem2101".to_string(),
+ "/dev/ttyUSB0".to_string(),
+ "/dev/ttyACM0".to_string(),
+ ],
+ baud_rate: 9600,
+ parser: EnhancedGnssParser::new().with_debug(debug_enabled),
+ is_running: Arc::new(Mutex::new(false)),
+ debug_enabled,
+ }
+ }
+
+ pub fn with_device_paths(mut self, paths: Vec) -> Self {
+ self.device_paths = paths;
+ self
+ }
+
+ pub fn with_baud_rate(mut self, baud_rate: u32) -> Self {
+ self.baud_rate = baud_rate;
+ self
+ }
+
+ pub fn with_debug(mut self, enabled: bool) -> Self {
+ self.debug_enabled = enabled;
+ self.parser = self.parser.with_debug(enabled);
+ self
+ }
+
+ /// Start streaming GPS data from the device
+ pub fn start_streaming(&self) -> Result, Box> {
+ let (tx, rx) = mpsc::channel(100);
+ let device_paths = self.device_paths.clone();
+ let baud_rate = self.baud_rate;
+ let parser = EnhancedGnssParser::new().with_debug(self.debug_enabled);
+ let is_running = Arc::clone(&self.is_running);
+ let debug_enabled = self.debug_enabled;
+
+ // Set running flag
+ {
+ let mut running = is_running.lock().unwrap();
+ *running = true;
+ }
+
+ thread::spawn(move || {
+ if debug_enabled {
+ info!("[GPS_DEBUG] Starting GPYes device streaming thread");
+ }
+
+ // Try to connect to any available device
+ let mut connected = false;
+ for device_path in &device_paths {
+ if debug_enabled {
+ info!("[GPS_DEBUG] Trying to connect to: {}", device_path);
+ }
+
+ match serialport::new(device_path, baud_rate)
+ .timeout(Duration::from_millis(1000))
+ .open()
+ {
+ Ok(mut port) => {
+ info!("Successfully connected to GPS device at {}", device_path);
+ connected = true;
+
+ let mut reader = BufReader::new(port.as_mut());
+ let mut line = String::new();
+
+ loop {
+ // Check if we should stop
+ {
+ let running = is_running.lock().unwrap();
+ if !*running {
+ if debug_enabled {
+ info!("[GPS_DEBUG] Stopping GPS streaming thread");
+ }
+ break;
+ }
+ }
+
+ line.clear();
+ match reader.read_line(&mut line) {
+ Ok(0) => {
+ warn!("GPS device disconnected (EOF)");
+ break;
+ }
+ Ok(_) => {
+ let sentence = line.trim();
+ if !sentence.is_empty() {
+ if debug_enabled {
+ debug!("[GPS_DEBUG] Raw NMEA: {}", sentence);
+ }
+
+ if let Some(location) = parser.parse_sentence(sentence) {
+ // Only send if we have valid position data
+ if location.latitude.is_some() && location.longitude.is_some() {
+ let gps_data: GpsData = location.into();
+ if debug_enabled {
+ debug!("[GPS_DEBUG] Sending GPS data: lat={:.6}, lon={:.6}, heading={:?}",
+ gps_data.latitude, gps_data.longitude, gps_data.heading);
+ }
+
+ if let Err(e) = tx.blocking_send(gps_data) {
+ error!("Failed to send GPS data: {}", e);
+ break;
+ }
+ }
+ }
+ }
+ }
+ Err(e) => {
+ match e.kind() {
+ ErrorKind::TimedOut => {
+ if debug_enabled {
+ debug!("[GPS_DEBUG] Read timeout - continuing...");
+ }
+ continue;
+ }
+ ErrorKind::Interrupted => {
+ continue;
+ }
+ _ => {
+ error!("Error reading from GPS device: {}", e);
+ break;
+ }
+ }
+ }
+ }
+ }
+ break;
+ }
+ Err(e) => {
+ if debug_enabled {
+ warn!("[GPS_DEBUG] Failed to connect to {}: {}", device_path, e);
+ }
+ }
+ }
+ }
+
+ if !connected {
+ error!("Could not connect to any GPS device. Tried paths: {:?}", device_paths);
+ }
+
+ // Clear running flag
+ {
+ let mut running = is_running.lock().unwrap();
+ *running = false;
+ }
+ });
+
+ Ok(rx)
+ }
+
+ /// Stop streaming GPS data
+ pub fn stop_streaming(&self) {
+ let mut running = self.is_running.lock().unwrap();
+ *running = false;
+ info!("GPS streaming stopped");
+ }
+
+ /// Check if the provider is currently streaming
+ pub fn is_streaming(&self) -> bool {
+ let running = self.is_running.lock().unwrap();
+ *running
+ }
+}
+
+impl Default for GpyesProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_enhanced_parser_creation() {
+ let parser = EnhancedGnssParser::new();
+ // Parser should be created successfully
+ }
+
+ #[test]
+ fn test_parse_gprmc_with_heading() {
+ let parser = EnhancedGnssParser::new();
+
+ // GPRMC sentence with course data (084.4 degrees)
+ let sentence = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A";
+
+ 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.speed.is_some());
+ assert!(location.heading.is_some());
+
+ // Check specific values
+ assert!((location.latitude.unwrap() - 48.1173).abs() < 0.001);
+ assert!((location.longitude.unwrap() - 11.5167).abs() < 0.001);
+ assert!((location.speed.unwrap() - 22.4).abs() < 0.1);
+ assert!((location.heading.unwrap() - 84.4).abs() < 0.1);
+ }
+
+ #[test]
+ fn test_parse_gpvtg_sentence() {
+ let parser = EnhancedGnssParser::new();
+
+ // GPVTG sentence with course and speed
+ let sentence = "$GPVTG,054.7,T,034.4,M,005.5,N,010.2,K*48";
+
+ let result = parser.parse_sentence(sentence);
+ assert!(result.is_some());
+
+ let location = result.unwrap();
+ assert!(location.heading.is_some());
+ assert!(location.speed.is_some());
+
+ // Check specific values
+ assert!((location.heading.unwrap() - 54.7).abs() < 0.1);
+ assert!((location.speed.unwrap() - 5.5).abs() < 0.1);
+ }
+
+ #[test]
+ fn test_enhanced_location_to_gps_data_conversion() {
+ let enhanced = EnhancedLocationData {
+ latitude: Some(48.1173),
+ longitude: Some(11.5167),
+ altitude: Some(545.4),
+ speed: Some(22.4),
+ heading: Some(84.4),
+ timestamp: Some("123519".to_string()),
+ fix_quality: Some(1),
+ satellites: Some(8),
+ hdop: Some(0.9),
+ };
+
+ let gps_data: GpsData = enhanced.into();
+ assert_eq!(gps_data.latitude, 48.1173);
+ assert_eq!(gps_data.longitude, 11.5167);
+ assert_eq!(gps_data.altitude, Some(545.4));
+ assert_eq!(gps_data.speed, Some(22.4));
+ assert_eq!(gps_data.heading, Some(84.4));
+ assert_eq!(gps_data.accuracy, Some(0.9));
+ }
+
+ #[test]
+ fn test_gpyes_provider_creation() {
+ let provider = GpyesProvider::new();
+ assert!(!provider.is_streaming());
+ assert_eq!(provider.baud_rate, 9600);
+ assert!(!provider.device_paths.is_empty());
+ }
+
+ #[test]
+ fn test_gpyes_provider_configuration() {
+ let provider = GpyesProvider::new()
+ .with_device_paths(vec!["/dev/test".to_string()])
+ .with_baud_rate(115200)
+ .with_debug(true);
+
+ assert_eq!(provider.device_paths, vec!["/dev/test"]);
+ assert_eq!(provider.baud_rate, 115200);
+ assert!(provider.debug_enabled);
+ }
+}
\ No newline at end of file
diff --git a/crates/yachtpit/src/services/mod.rs b/crates/yachtpit/src/services/mod.rs
index 41fdedc..b21e7c4 100644
--- a/crates/yachtpit/src/services/mod.rs
+++ b/crates/yachtpit/src/services/mod.rs
@@ -1,3 +1,6 @@
pub mod gps_service;
+#[cfg(not(target_arch = "wasm32"))]
+pub mod gpyes_provider;
+
pub use gps_service::*;
\ No newline at end of file
diff --git a/yachtpit.png b/yachtpit-og.png
similarity index 100%
rename from yachtpit.png
rename to yachtpit-og.png
diff --git a/yachtpit-x.png b/yachtpit-x.png
new file mode 100644
index 0000000..7cb739f
Binary files /dev/null and b/yachtpit-x.png differ