Gpyes integration (#11)

* Introduce core modules: device management, bus communication, and discovery protocol. Adds system device interface, virtual hardware bus, and device discovery logic. Includes tests for all components.

* improve map
- Fix typos in variable and function names (`vessle` to `vessel`).
- Add `update_vessel_data_with_gps` function to enable GPS integration for vessel data updates.
- Integrate real GPS data into vessel systems and UI components (speed, heading, etc.).
- Initialize speed gauge display at 0 kts.
- Include `useEffect` in `MapNext` to log and potentially handle `vesselPosition` changes.

**Add compass heading update system using GPS heading data.**

- Remove `UserLocationMarker` component and related code from `MapNext.tsx`
- Simplify logic for layer selection and navigation within `App.tsx`
- Replace map style 'Bathymetry' with 'OSM' in layer options

improve map

* update image

---------

Co-authored-by: geoffsee <>
This commit is contained in:
Geoff Seemueller
2025-07-20 15:51:33 -04:00
committed by GitHub
parent 2311f43d97
commit e029ef48fc
28 changed files with 4557 additions and 207 deletions

View File

@@ -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

1
.gitignore vendored
View File

@@ -37,3 +37,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/crates/yachtpit/assets/ui/assets/ /crates/yachtpit/assets/ui/assets/
/crates/yachtpit/assets/ui/packages/base-map/dist/ /crates/yachtpit/assets/ui/packages/base-map/dist/
/crates/base-map/map/src/map-upgrade/

23
Cargo.lock generated
View File

@@ -4274,6 +4274,26 @@ dependencies = [
"redox_syscall 0.5.13", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -6573,6 +6593,7 @@ dependencies = [
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"io-kit-sys", "io-kit-sys",
"libudev",
"mach2", "mach2",
"nix 0.26.4", "nix 0.26.4",
"scopeguard", "scopeguard",
@@ -9088,6 +9109,7 @@ checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
name = "yachtpit" name = "yachtpit"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base-map",
"bevy", "bevy",
"bevy_asset_loader", "bevy_asset_loader",
"bevy_flurx", "bevy_flurx",
@@ -9102,6 +9124,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"serialport",
"systems", "systems",
"tokio", "tokio",
"wasm-bindgen", "wasm-bindgen",

View File

@@ -4,7 +4,7 @@
> Warning: Experimental, incomplete, and unfunded. > Warning: Experimental, incomplete, and unfunded.
<p align="center"> <p align="center">
<img src="https://github.com/seemueller-io/yachtpit/blob/main/yachtpit.png?raw=true" width="250" /> <img src="https://github.com/seemueller-io/yachtpit/blob/main/yachtpit-x.png?raw=true" width="372" />
</p> </p>
<p align="center"> <p align="center">

View File

@@ -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 'mapbox-gl/dist/mapbox-gl.css';
import {Box, Button, HStack, Input} from '@chakra-ui/react'; import {Box, Button, HStack, Input} from '@chakra-ui/react';
import {useCallback, useEffect, useState} from "react"; import {useCallback, useEffect, useState} from "react";
import MapNext from "@/MapNext.tsx"; import MapNext, {type Geolocation} from "@/MapNext.tsx";
// import type {FeatureCollection} from 'geojson';
// import type {CircleLayerSpecification} from "mapbox-gl";
// public key // public key
const key = const key =
'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn'; 'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
const layers = [
{ name: 'OSM', value: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
];
// const vesselLayerStyle: CircleLayerSpecification = { // const vesselLayerStyle: CircleLayerSpecification = {
@@ -39,6 +40,31 @@ interface VesselStatus {
speed: number; 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 { // interface MapViewParams {
// latitude: number; // latitude: number;
// longitude: number; // longitude: number;
@@ -50,9 +76,58 @@ interface VesselStatus {
// token: string | null; // token: string | null;
// } // }
function LayerSelector(props: { onClick: (e: any) => Promise<void> }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Box position="relative">
<Button colorScheme="blue" size="sm" variant="solid" onClick={() => setIsOpen(!isOpen)}>
Layer
</Button>
{isOpen && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
bg="rgba(0, 0, 0, 0.8)"
boxShadow="md"
zIndex={2}
>
{layers.map(layer => (
<Box
key={layer.value}
id={layer.value}
p={2}
cursor="pointer"
color="white"
_hover={{ bg: 'whiteAlpha.200' }}
onClick={async e => {
setIsOpen(false);
await props.onClick(e);
}}
>
{layer.name}
</Box>
))}
</Box>
)}
</Box>
);
}
function App() { function App() {
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [mapView, setMapView] = useState({
longitude: -122.4,
latitude: 37.8,
zoom: 14
});
// Map state that can be updated from Rust // Map state that can be updated from Rust
// const [mapView, setMapView] = useState({ // const [mapView, setMapView] = useState({
@@ -62,7 +137,7 @@ function App() {
// }); // });
// Vessel position state // Vessel position state
// const [vesselPosition, setVesselPosition] = useState<VesselStatus | null>(null); const [vesselPosition, setVesselPosition] = useState<VesselStatus | null>(null);
// Create vessel geojson data // Create vessel geojson data
// const vesselGeojson: FeatureCollection = { // const vesselGeojson: FeatureCollection = {
@@ -85,20 +160,51 @@ function App() {
// Button click handlers // Button click handlers
const handleNavigationClick = useCallback(async () => { // const handleNavigationClick = useCallback(async () => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) { // if (typeof window !== 'undefined' && (window as any).__FLURX__) {
try { // try {
await (window as any).__FLURX__.invoke("navigation_clicked"); // await (window as any).__FLURX__.invoke("navigation_clicked");
console.log('Navigation clicked'); // console.log('Navigation clicked');
} catch (error) { // } catch (error) {
console.error('Failed to invoke navigation_clicked:', 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 () => { const handleSearchClick = useCallback(async () => {
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); setIsSearchOpen(!isSearchOpen);
}
if (typeof window !== 'undefined' && (window as any).__FLURX__) { if (typeof window !== 'undefined' && (window as any).__FLURX__) {
try { try {
await (window as any).__FLURX__.invoke("search_clicked"); await (window as any).__FLURX__.invoke("search_clicked");
@@ -107,6 +213,14 @@ function App() {
console.error('Failed to invoke search_clicked:', error); 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) => { // const handleMapViewChange = useCallback(async (evt: any) => {
@@ -135,7 +249,7 @@ function App() {
try { try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status"); const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
console.log('Vessel status:', vesselStatus); console.log('Vessel status:', vesselStatus);
// setVesselPosition(vesselStatus); setVesselPosition(vesselStatus);
} catch (error) { } catch (error) {
console.error('Failed to get vessel status:', error); console.error('Failed to get vessel status:', error);
} }
@@ -175,6 +289,28 @@ function App() {
return ( return (
/* Full-screen wrapper — fills the viewport and becomes the positioning context */ /* Full-screen wrapper — fills the viewport and becomes the positioning context */
<Box w="100vw" h="100vh" position="relative" overflow="hidden"> <Box w="100vw" h="100vh" position="relative" overflow="hidden">
{/* GPS Feed Display — absolutely positioned at bottom-left */}
{vesselPosition && (
<Box
position="absolute"
top={65}
right={4}
zIndex={1}
bg="rgba(0, 0, 0, 0.8)"
color="white"
p={3}
borderRadius="md"
fontSize="sm"
fontFamily="monospace"
minW="200px"
>
<Box fontWeight="bold" mb={2}>GPS Feed</Box>
<Box>Lat: {vesselPosition.latitude.toFixed(6)}°</Box>
<Box>Lon: {vesselPosition.longitude.toFixed(6)}°</Box>
<Box>Heading: {vesselPosition.heading.toFixed(1)}°</Box>
<Box>Speed: {vesselPosition.speed.toFixed(1)} kts</Box>
</Box>
)}
{/* Button bar — absolutely positioned inside the wrapper */} {/* Button bar — absolutely positioned inside the wrapper */}
<HStack position="absolute" top={4} right={4} zIndex={1}> <HStack position="absolute" top={4} right={4} zIndex={1}>
<Box <Box
@@ -194,28 +330,206 @@ function App() {
w="200px" w="200px"
transition="all 0.3s" transition="all 0.3s"
transform={`translateX(${isSearchOpen ? "0" : "100%"})`} transform={`translateX(${isSearchOpen ? "0" : "100%"})`}
background="rgba(0, 0, 0, 0.8)"
opacity={isSearchOpen ? 1 : 0} opacity={isSearchOpen ? 1 : 0}
color="white" color="white"
> >
<Input <Input
placeholder="Search..." placeholder="Search..."
size="sm" size="sm"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
color="white"
bg="rgba(0, 0, 0, 0.8)"
border="none"
borderRadius="0"
_focus={{
outline: 'none',
}}
_placeholder={{ _placeholder={{
color: "#d1cfcf" color: "#d1cfcf"
}} }}
/> />
{searchResults.length > 0 && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
bg="rgba(0, 0, 0, 0.8)"
boxShadow="md"
zIndex={2}
>
{searchResults.map((result, index) => (
<Box
key={index}
p={2}
cursor="pointer"
color="white"
_hover={{ bg: 'whiteAlpha.200' }}
onClick={async () => {
console.log(`Selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}}
>
{`${result.lat}, ${result.lon}`}
</Box>
))}
</Box>
)}
</Box>} </Box>}
</Box> </Box>
<Button <LayerSelector onClick={handleLayerChange} />
colorScheme="blue"
size="sm"
variant="solid"
onClick={handleNavigationClick}
>
Layer
</Button>
</HStack> </HStack>
<MapNext mapboxPublicKey={atob(key)}/> <MapNext mapboxPublicKey={atob(key)} vesselPosition={vesselPosition} layer={selectedLayer} mapView={mapView} geolocation={new MyGeolocation({
clearWatch: (watchId: number) => {
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);
}
},
})}/>
{/*<Map*/} {/*<Map*/}
{/* mapboxAccessToken={atob(key)}*/} {/* mapboxAccessToken={atob(key)}*/}
{/* initialViewState={mapView}*/} {/* initialViewState={mapView}*/}

View File

@@ -1,4 +1,4 @@
import {useState, useMemo} from 'react'; import {useState, useMemo, useEffect} from 'react';
import Map, { import Map, {
Marker, Marker,
Popup, Popup,
@@ -15,7 +15,18 @@ import PORTS from './test_data/nautical-base-data.json';
import {Box} from "@chakra-ui/react"; import {Box} from "@chakra-ui/react";
export default function MapNext(props: any = {mapboxPublicKey: ""} as any) { export interface Geolocation {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Geolocation/clearWatch) */
clearWatch(watchId: number): void;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Geolocation/getCurrentPosition) */
getCurrentPosition(successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions): void;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Geolocation/watchPosition) */
watchPosition(successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions): number;
}
export default function MapNext(props: any = {mapboxPublicKey: "", geolocation: Geolocation, vesselPosition: undefined, layer: undefined, mapView: undefined} as any) {
const [popupInfo, setPopupInfo] = useState(null); const [popupInfo, setPopupInfo] = useState(null);
const pins = useMemo( const pins = useMemo(
@@ -44,22 +55,29 @@ export default function MapNext(props: any = {mapboxPublicKey: ""} as any) {
[] []
); );
useEffect(() => {
console.log("props.vesselPosition", props?.vesselPosition);
// setLocationLock(props.vesselPosition)
}, [props.vesselPosition]);
return ( return (
<Box> <Box>
<Map <Map
initialViewState={{ initialViewState={{
latitude: 40, latitude: props.mapView?.latitude || 40,
longitude: -100, longitude: props.mapView?.longitude || -100,
zoom: 3.5, zoom: props.mapView?.zoom || 3.5,
bearing: 0, bearing: 0,
pitch: 0 pitch: 0
}} }}
key={`${props.mapView?.latitude}-${props.mapView?.longitude}-${props.mapView?.zoom}`}
mapStyle="mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y" mapStyle={props.layer?.value || "mapbox://styles/mapbox/standard"}
mapboxAccessToken={props.mapboxPublicKey} mapboxAccessToken={props.mapboxPublicKey}
style={{position: "fixed", width: '100%', height: '100%', bottom: 0, top: 0, left: 0, right: 0}} style={{position: "fixed", width: '100%', height: '100%', bottom: 0, top: 0, left: 0, right: 0}}
> >
<GeolocateControl showUserHeading={true} showUserLocation={true} position="top-left" /> <GeolocateControl showUserHeading={true} showUserLocation={true} geolocation={props.geolocation} position="top-left" />
<FullscreenControl position="top-left" /> <FullscreenControl position="top-left" />
<NavigationControl position="top-left" /> <NavigationControl position="top-left" />
<ScaleControl /> <ScaleControl />
@@ -107,6 +125,9 @@ export default function MapNext(props: any = {mapboxPublicKey: ""} as any) {
<img width="100%" src={popupInfo.image} /> <img width="100%" src={popupInfo.image} />
</Popup> </Popup>
)} )}
</Map> </Map>
<ControlPanel /> <ControlPanel />

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
/**
* UserLocationMarker
* • size overall diameter in px (default 24)
* • color dot / ring colour (default #1E90FF ⟵ systemblue)
* • pulse adds a subtle accuracyhalo 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 pageload if pulse is ever enabled
React.useEffect(() => {
if (!pulse || document.getElementById('ulmpulsekf')) return;
const styleTag = document.createElement('style');
styleTag.id = 'ulmpulsekf';
styleTag.textContent = `
@keyframes ulmpulse {
0% { r: 0; opacity: .6; }
70% { r: 12px; opacity: 0; }
100% { r: 12px; opacity: 0; }
}`;
document.head.appendChild(styleTag);
}, [pulse]);
return (
<svg
height={size}
width={size}
viewBox="0 0 24 24"
style={{
display: 'block',
transform: 'translate(-50%, -50%)', // center on map coordinate
pointerEvents: 'none' // let clicks pass through
}}
>
{/* accuracy halo (animated when pulse=true) */}
{pulse && (
<circle
cx="12"
cy="12"
r="0"
fill={color}
opacity=".6"
style={{
animation: 'ulmpulse 2s ease-out infinite'
}}
/>
)}
{/* outer ring */}
<circle
cx="12"
cy="12"
r={size / 2 - strokeWidth}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
/>
{/* inner dot */}
<circle
cx="12"
cy="12"
r={size * 0.25} /* ≈ 6px when size = 24 */
fill={color}
/>
</svg>
);
}
export default React.memo(UserLocationMarker);

View File

@@ -39,7 +39,7 @@ pub fn setup_instrument_cluster(mut commands: Commands) {
)) ))
.with_children(|gauge| { .with_children(|gauge| {
gauge.spawn(create_text("SPEED", FONT_SIZE_SMALL, TEXT_COLOR_PRIMARY)); 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)); gauge.spawn(create_text("KTS", FONT_SIZE_SMALL, TEXT_COLOR_SECONDARY));
}); });

View File

@@ -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<VesselData>, time: Res<Time>) { pub fn update_vessel_data(mut vessel_data: ResMut<VesselData>, time: Res<Time>) {
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<VesselData>,
time: Res<Time>,
gps_data: Option<(f64, f64)> // (speed, heading)
) {
let t = time.elapsed_secs(); let t = time.elapsed_secs();
// 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 // Simulate realistic yacht data with some variation
vessel_data.speed = 12.5 + (t * 0.3).sin() * 2.0; vessel_data.speed = 12.5 + (t * 0.3).sin() * 2.0;
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.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.engine_temp = 82.0 + (t * 0.2).sin() * 3.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_speed = 8.3 + (t * 0.4).sin() * 1.5;
vessel_data.wind_direction = (vessel_data.wind_direction + time.delta_secs() * 10.0) % 360.0; vessel_data.wind_direction = (vessel_data.wind_direction + time.delta_secs() * 10.0) % 360.0;

View File

@@ -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<f64>,
pub longitude: Option<f64>,
pub altitude: Option<f64>,
pub speed: Option<f64>,
pub timestamp: Option<String>,
pub fix_quality: Option<u8>,
pub satellites: Option<u8>,
}
impl Default for LocationData {
fn default() -> Self {
LocationData {
latitude: None,
longitude: None,
altitude: None,
speed: None,
timestamp: None,
fix_quality: None,
satellites: None,
}
}
}
pub struct GnssParser;
impl GnssParser {
pub fn new() -> Self {
GnssParser
}
pub fn parse_sentence(&self, sentence: &str) -> Option<LocationData> {
if sentence.is_empty() || !sentence.starts_with('$') {
return None;
}
let parts: Vec<&str> = sentence.split(',').collect();
if parts.is_empty() {
return None;
}
let sentence_type = parts[0];
match sentence_type {
"$GPGGA" | "$GNGGA" => self.parse_gpgga(&parts),
"$GPRMC" | "$GNRMC" => self.parse_gprmc(&parts),
_ => None,
}
}
fn parse_gpgga(&self, parts: &[&str]) -> Option<LocationData> {
if parts.len() < 15 {
return None;
}
let mut location = LocationData::default();
// Parse timestamp (field 1)
if !parts[1].is_empty() {
location.timestamp = Some(parts[1].to_string());
}
// Parse latitude (fields 2 and 3)
if !parts[2].is_empty() && !parts[3].is_empty() {
if let Ok(lat_raw) = parts[2].parse::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[3] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
}
}
// Parse longitude (fields 4 and 5)
if !parts[4].is_empty() && !parts[5].is_empty() {
if let Ok(lon_raw) = parts[4].parse::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[5] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
}
}
// Parse fix quality (field 6)
if !parts[6].is_empty() {
if let Ok(quality) = parts[6].parse::<u8>() {
location.fix_quality = Some(quality);
}
}
// Parse number of satellites (field 7)
if !parts[7].is_empty() {
if let Ok(sats) = parts[7].parse::<u8>() {
location.satellites = Some(sats);
}
}
// Parse altitude (field 9)
if !parts[9].is_empty() {
if let Ok(alt) = parts[9].parse::<f64>() {
location.altitude = Some(alt);
}
}
Some(location)
}
fn parse_gprmc(&self, parts: &[&str]) -> Option<LocationData> {
if parts.len() < 12 {
return None;
}
let mut location = LocationData::default();
// Parse timestamp (field 1)
if !parts[1].is_empty() {
location.timestamp = Some(parts[1].to_string());
}
// Check if data is valid (field 2)
if parts[2] != "A" {
return None; // Invalid data
}
// Parse latitude (fields 3 and 4)
if !parts[3].is_empty() && !parts[4].is_empty() {
if let Ok(lat_raw) = parts[3].parse::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[4] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
}
}
// Parse longitude (fields 5 and 6)
if !parts[5].is_empty() && !parts[6].is_empty() {
if let Ok(lon_raw) = parts[5].parse::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[6] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
}
}
// Parse speed (field 7) - in knots
if !parts[7].is_empty() {
if let Ok(speed_knots) = parts[7].parse::<f64>() {
location.speed = Some(speed_knots);
}
}
Some(location)
}
}
#[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<Mutex<VecDeque<DataMessage>>>,
shutdown_tx: Option<mpsc::Sender<()>>,
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<GpyesSourceConfig> {
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::<u32>()
.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::<u16>()
.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::<u16>()
.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::<f64>()
.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<DataMessage> {
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<Option<DataMessage>> {
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());
}
}

View File

@@ -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"

366
crates/hardware/README.md Normal file
View File

@@ -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<Position>,
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<Vec<BusMessage>> {
// 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<HardwareBus>,
device_manager: DeviceManager,
discovery_protocol: DiscoveryProtocol,
}
impl HardwareManager {
pub async fn new() -> Result<Self> {
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<dyn SystemDevice>) -> 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<HardwareManager> {
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<HardwareManager>,
}
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<Vec<DeviceInfo>> {
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.

338
crates/hardware/src/bus.rs Normal file
View File

@@ -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<String>) -> 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<String>) -> 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<u8>,
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<u8>,
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<BusAddress> },
}
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<BusMessage>,
pub receiver: mpsc::UnboundedReceiver<BusMessage>,
}
/// Virtual Hardware Bus implementation
pub struct HardwareBus {
devices: Arc<RwLock<HashMap<BusAddress, mpsc::UnboundedSender<BusMessage>>>>,
message_log: Arc<RwLock<Vec<BusMessage>>>,
}
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<DeviceConnection> {
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<BusAddress> {
let devices = self.devices.read().await;
devices.keys().cloned().collect()
}
/// Get message history
pub async fn get_message_history(&self) -> Vec<BusMessage> {
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"),
}
}
}

View File

@@ -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<DeviceCapability>,
/// 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<String, String>,
}
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<Option<BusMessage>>;
/// Process device-specific logic (called periodically)
async fn process(&mut self) -> Result<Vec<BusMessage>>;
/// Get device capabilities
fn get_capabilities(&self) -> Vec<DeviceCapability>;
/// 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<mpsc::UnboundedSender<BusMessage>>,
pub message_receiver: Option<mpsc::UnboundedReceiver<BusMessage>>,
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<BusMessage>,
receiver: mpsc::UnboundedReceiver<BusMessage>,
) {
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<Option<BusMessage>> {
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<Vec<BusMessage>> {
// Base implementation does nothing
Ok(vec![])
}
fn get_capabilities(&self) -> Vec<DeviceCapability> {
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<BusAddress, Box<dyn SystemDevice>>,
}
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<dyn SystemDevice>) {
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<Box<dyn SystemDevice>> {
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<dyn SystemDevice>> {
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<DeviceInfo> {
self.devices.values().map(|d| d.get_info()).collect()
}
/// Process all devices
pub async fn process_all(&mut self) -> Result<Vec<BusMessage>> {
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);
}
}

View File

@@ -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<DiscoveryFilter>,
timestamp: SystemTime,
},
/// Response to discovery request
DiscoverResponse {
devices: Vec<DeviceInfo>,
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<Vec<DeviceCapability>>,
/// Filter by device name pattern
pub name_pattern: Option<String>,
/// Filter by manufacturer
pub manufacturer: Option<String>,
/// Filter by minimum version
pub min_version: Option<String>,
}
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<DeviceCapability>) -> Self {
self.capabilities = Some(capabilities);
self
}
/// Filter by name pattern
pub fn with_name_pattern(mut self, pattern: impl Into<String>) -> Self {
self.name_pattern = Some(pattern.into());
self
}
/// Filter by manufacturer
pub fn with_manufacturer(mut self, manufacturer: impl Into<String>) -> 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<RwLock<HashMap<BusAddress, DeviceInfo>>>,
/// Configuration
config: DiscoveryConfig,
/// Message sender for bus communication
message_sender: Option<mpsc::UnboundedSender<BusMessage>>,
/// Discovery message receiver
discovery_receiver: Option<mpsc::UnboundedReceiver<DiscoveryMessage>>,
/// 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<BusMessage>) {
self.message_sender = Some(sender);
}
/// Set the discovery message receiver
pub fn set_discovery_receiver(&mut self, receiver: mpsc::UnboundedReceiver<DiscoveryMessage>) {
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<DiscoveryFilter>) -> 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<DeviceInfo> {
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<DeviceInfo> {
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<DeviceInfo> {
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<DiscoveryFilter>,
) -> 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<DeviceInfo>) -> 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);
}
}

View File

@@ -0,0 +1,72 @@
//! Error types for the hardware abstraction layer
use thiserror::Error;
/// Result type alias for hardware operations
pub type Result<T> = std::result::Result<T, HardwareError>;
/// 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<String>) -> Self {
Self::Generic {
message: message.into(),
}
}
/// Create a new bus communication error
pub fn bus_communication(message: impl Into<String>) -> Self {
Self::BusCommunicationError {
message: message.into(),
}
}
/// Create a new device not found error
pub fn device_not_found(device_id: impl Into<String>) -> Self {
Self::DeviceNotFound {
device_id: device_id.into(),
}
}
/// Create a new discovery error
pub fn discovery_error(message: impl Into<String>) -> Self {
Self::DiscoveryError {
message: message.into(),
}
}
}

View File

@@ -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<f64>,
pub longitude: Option<f64>,
pub altitude: Option<f64>,
pub speed: Option<f64>,
pub timestamp: Option<String>,
pub fix_quality: Option<u8>,
pub satellites: Option<u8>,
}
impl Default for LocationData {
fn default() -> Self {
LocationData {
latitude: None,
longitude: None,
altitude: None,
speed: None,
timestamp: None,
fix_quality: None,
satellites: None,
}
}
}
/// GNSS parser for NMEA sentences
pub struct GnssParser;
impl GnssParser {
pub fn new() -> Self {
GnssParser
}
pub fn parse_sentence(&self, sentence: &str) -> Option<LocationData> {
if sentence.is_empty() || !sentence.starts_with('$') {
return None;
}
let parts: Vec<&str> = sentence.split(',').collect();
if parts.is_empty() {
return None;
}
let sentence_type = parts[0];
match sentence_type {
"$GPGGA" | "$GNGGA" => self.parse_gpgga(&parts),
"$GPRMC" | "$GNRMC" => self.parse_gprmc(&parts),
_ => None,
}
}
fn parse_gpgga(&self, parts: &[&str]) -> Option<LocationData> {
if parts.len() < 15 {
return None;
}
let mut location = LocationData::default();
// Parse timestamp (field 1)
if !parts[1].is_empty() {
location.timestamp = Some(parts[1].to_string());
}
// Parse latitude (fields 2 and 3)
if !parts[2].is_empty() && !parts[3].is_empty() {
if let Ok(lat_raw) = parts[2].parse::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[3] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
}
}
// Parse longitude (fields 4 and 5)
if !parts[4].is_empty() && !parts[5].is_empty() {
if let Ok(lon_raw) = parts[4].parse::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[5] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
}
}
// Parse fix quality (field 6)
if !parts[6].is_empty() {
if let Ok(quality) = parts[6].parse::<u8>() {
location.fix_quality = Some(quality);
}
}
// Parse number of satellites (field 7)
if !parts[7].is_empty() {
if let Ok(sats) = parts[7].parse::<u8>() {
location.satellites = Some(sats);
}
}
// Parse altitude (field 9)
if !parts[9].is_empty() {
if let Ok(alt) = parts[9].parse::<f64>() {
location.altitude = Some(alt);
}
}
Some(location)
}
fn parse_gprmc(&self, parts: &[&str]) -> Option<LocationData> {
if parts.len() < 12 {
return None;
}
let mut location = LocationData::default();
// Parse timestamp (field 1)
if !parts[1].is_empty() {
location.timestamp = Some(parts[1].to_string());
}
// Check if data is valid (field 2)
if parts[2] != "A" {
return None; // Invalid data
}
// Parse latitude (fields 3 and 4)
if !parts[3].is_empty() && !parts[4].is_empty() {
if let Ok(lat_raw) = parts[3].parse::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[4] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
}
}
// Parse longitude (fields 5 and 6)
if !parts[5].is_empty() && !parts[6].is_empty() {
if let Ok(lon_raw) = parts[5].parse::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[6] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
}
}
// Parse speed (field 7) - in knots
if !parts[7].is_empty() {
if let Ok(speed_knots) = parts[7].parse::<f64>() {
location.speed = Some(speed_knots);
}
}
Some(location)
}
}
/// GPS Device configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpsDeviceConfig {
pub device_paths: Vec<String>,
pub baud_rate: u32,
pub timeout_ms: u64,
pub auto_reconnect: bool,
pub broadcast_interval_ms: u64,
}
impl Default for GpsDeviceConfig {
fn default() -> Self {
GpsDeviceConfig {
device_paths: vec![
"/dev/tty.usbmodem2101".to_string(),
"/dev/cu.usbmodem2101".to_string(),
"/dev/ttyUSB0".to_string(),
"/dev/ttyACM0".to_string(),
],
baud_rate: 9600,
timeout_ms: 1000,
auto_reconnect: true,
broadcast_interval_ms: 1000,
}
}
}
/// GPS Device implementation
pub struct GpsDevice {
device_info: DeviceInfo,
gps_config: GpsDeviceConfig,
parser: GnssParser,
last_location: Option<LocationData>,
serial_port: Option<Arc<Mutex<Box<dyn serialport::SerialPort>>>>,
running: bool,
}
impl GpsDevice {
pub fn new(gps_config: GpsDeviceConfig) -> Self {
let address = BusAddress::new("GPS_DEVICE");
// Create device config for the hardware abstraction layer
let device_config = DeviceConfig {
name: "GPS Device".to_string(),
capabilities: vec![DeviceCapability::Gps, DeviceCapability::Navigation],
update_interval_ms: gps_config.broadcast_interval_ms,
max_queue_size: 100,
custom_config: HashMap::new(),
};
let device_info = DeviceInfo {
address,
config: device_config,
status: DeviceStatus::Offline,
last_seen: SystemTime::now(),
version: "1.0.0".to_string(),
manufacturer: "YachtPit".to_string(),
};
GpsDevice {
device_info,
gps_config,
parser: GnssParser::new(),
last_location: None,
serial_port: None,
running: false,
}
}
pub fn with_address(mut self, address: BusAddress) -> Self {
self.device_info.address = address;
self
}
async fn try_connect_serial(&mut self) -> Result<()> {
for device_path in &self.gps_config.device_paths {
debug!("Attempting to connect to GPS device at: {}", device_path);
match serialport::new(device_path, self.gps_config.baud_rate)
.timeout(Duration::from_millis(self.gps_config.timeout_ms))
.open()
{
Ok(port) => {
info!("Successfully connected to GPS device at {}", device_path);
self.serial_port = Some(Arc::new(Mutex::new(port)));
return Ok(());
}
Err(e) => {
debug!("Failed to connect to {}: {}", device_path, e);
}
}
}
Err(HardwareError::generic(
"Could not connect to any GPS device"
))
}
async fn read_and_parse_gps_data(&mut self) -> Result<Vec<BusMessage>> {
let mut messages = Vec::new();
if let Some(ref serial_port) = self.serial_port {
let mut port_guard = serial_port.lock().await;
let mut reader = BufReader::new(port_guard.as_mut());
let mut line = String::new();
// Try to read a line with timeout handling
match reader.read_line(&mut line) {
Ok(0) => {
// EOF - connection lost
warn!("GPS device connection lost (EOF)");
drop(port_guard); // Release the lock before modifying self
self.serial_port = None;
self.device_info.status = DeviceStatus::Offline;
}
Ok(_) => {
let sentence = line.trim();
if !sentence.is_empty() {
debug!("Received GPS sentence: {}", sentence);
if let Some(location) = self.parser.parse_sentence(sentence) {
info!("Parsed GPS location: {:?}", location);
self.last_location = Some(location.clone());
// Create broadcast message with location data
if let Ok(payload) = serde_json::to_vec(&location) {
let message = BusMessage::Broadcast {
from: self.device_info.address.clone(),
payload,
message_id: Uuid::new_v4(),
};
messages.push(message);
}
}
}
}
Err(e) => {
match e.kind() {
ErrorKind::TimedOut => {
// Timeout is normal, just continue
debug!("GPS read timeout - continuing");
}
ErrorKind::Interrupted => {
// Interrupted system call - continue
debug!("GPS read interrupted - continuing");
}
_ => {
error!("Error reading from GPS device: {}", e);
drop(port_guard); // Release the lock before modifying self
self.serial_port = None;
self.device_info.status = DeviceStatus::Error {
message: format!("GPS read error: {}", e),
};
}
}
}
}
}
Ok(messages)
}
pub fn get_last_location(&self) -> Option<&LocationData> {
self.last_location.as_ref()
}
}
#[async_trait::async_trait]
impl SystemDevice for GpsDevice {
async fn initialize(&mut self) -> Result<()> {
info!("Initializing GPS device");
self.device_info.status = DeviceStatus::Initializing;
self.device_info.last_seen = SystemTime::now();
// Try to connect to GPS hardware
match self.try_connect_serial().await {
Ok(()) => {
self.device_info.status = DeviceStatus::Online;
info!("GPS device initialized successfully");
Ok(())
}
Err(e) => {
warn!("GPS device initialization failed: {}", e);
self.device_info.status = DeviceStatus::Error {
message: format!("Initialization failed: {}", e),
};
// Return error when hardware GPS is not available
Err(e)
}
}
}
async fn start(&mut self) -> Result<()> {
info!("Starting GPS device");
self.running = true;
self.device_info.last_seen = SystemTime::now();
if self.serial_port.is_some() {
self.device_info.status = DeviceStatus::Online;
} else {
// Try to reconnect if auto-reconnect is enabled
if self.gps_config.auto_reconnect {
if let Ok(()) = self.try_connect_serial().await {
self.device_info.status = DeviceStatus::Online;
}
}
}
Ok(())
}
async fn stop(&mut self) -> Result<()> {
info!("Stopping GPS device");
self.running = false;
self.serial_port = None;
self.device_info.status = DeviceStatus::Offline;
self.device_info.last_seen = SystemTime::now();
Ok(())
}
fn get_info(&self) -> DeviceInfo {
self.device_info.clone()
}
fn get_status(&self) -> DeviceStatus {
self.device_info.status.clone()
}
async fn handle_message(&mut self, message: BusMessage) -> Result<Option<BusMessage>> {
debug!("GPS device received message: {:?}", message);
self.device_info.last_seen = SystemTime::now();
match message {
BusMessage::Control { command, .. } => {
match command {
crate::bus::ControlCommand::Ping { target } => {
if target == self.device_info.address {
return Ok(Some(BusMessage::Control {
from: self.device_info.address.clone(),
command: crate::bus::ControlCommand::Pong {
from: self.device_info.address.clone(),
},
message_id: Uuid::new_v4(),
}));
}
}
_ => {}
}
}
_ => {}
}
Ok(None)
}
async fn process(&mut self) -> Result<Vec<BusMessage>> {
if !self.running {
return Ok(Vec::new());
}
self.device_info.last_seen = SystemTime::now();
// Try to read GPS data if connected
if self.serial_port.is_some() {
self.read_and_parse_gps_data().await
} else if self.gps_config.auto_reconnect {
// Try to reconnect
if let Ok(()) = self.try_connect_serial().await {
info!("GPS device reconnected successfully");
self.device_info.status = DeviceStatus::Online;
}
Ok(Vec::new())
} else {
Ok(Vec::new())
}
}
fn get_capabilities(&self) -> Vec<DeviceCapability> {
self.device_info.config.capabilities.clone()
}
async fn update_config(&mut self, _config: DeviceConfig) -> Result<()> {
// For now, we don't support dynamic config updates
// In a real implementation, you might want to parse the config
// and update the GPS-specific settings
warn!("GPS device config update not implemented");
self.device_info.last_seen = SystemTime::now();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gnss_parser_creation() {
let parser = GnssParser::new();
// Parser should be created successfully
}
#[test]
fn test_parse_gpgga_sentence() {
let parser = GnssParser::new();
let sentence = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47";
let result = parser.parse_sentence(sentence);
assert!(result.is_some());
let location = result.unwrap();
assert!(location.latitude.is_some());
assert!(location.longitude.is_some());
assert!(location.altitude.is_some());
assert!((location.latitude.unwrap() - 48.1173).abs() < 0.001);
assert!((location.longitude.unwrap() - 11.5167).abs() < 0.001);
}
#[test]
fn test_gps_device_creation() {
let config = GpsDeviceConfig::default();
let device = GpsDevice::new(config);
assert_eq!(device.get_status(), DeviceStatus::Offline);
assert!(device.get_capabilities().contains(&DeviceCapability::Gps));
}
#[test]
fn test_location_data_serialization() {
let location = LocationData {
latitude: Some(48.1173),
longitude: Some(11.5167),
altitude: Some(545.4),
speed: Some(22.4),
timestamp: Some("123519".to_string()),
fix_quality: Some(1),
satellites: Some(8),
};
let serialized = serde_json::to_string(&location).unwrap();
let deserialized: LocationData = serde_json::from_str(&serialized).unwrap();
assert_eq!(location, deserialized);
}
}

View File

@@ -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,
};
}

View File

@@ -9,7 +9,7 @@ mod geo_plugin;
// Re-export components from the components crate // Re-export components from the components crate
pub use components::{ 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, SpeedGauge, DepthGauge, CompassGauge, EngineStatus, NavigationDisplay,
InstrumentCluster, GpsIndicator, RadarIndicator, AisIndicator, SystemDisplay InstrumentCluster, GpsIndicator, RadarIndicator, AisIndicator, SystemDisplay
}; };

View File

@@ -62,8 +62,8 @@ mod tests {
assert_eq!(gps.display_name(), "GPS Navigation"); assert_eq!(gps.display_name(), "GPS Navigation");
assert_eq!(gps.status(), SystemStatus::Active); assert_eq!(gps.status(), SystemStatus::Active);
let vessle_data = VesselData::default(); let vessel_data = VesselData::default();
let display = gps.render_display(&vessle_data); let display = gps.render_display(&vessel_data);
assert!(display.contains("GPS NAVIGATION SYSTEM")); assert!(display.contains("GPS NAVIGATION SYSTEM"));
assert!(display.contains("Satellites: 12 connected")); assert!(display.contains("Satellites: 12 connected"));
} }

View File

@@ -90,7 +90,9 @@ bevy_webview_wry = { version = "0.4", default-features = false, features = ["api
bevy_flurx = "0.11" bevy_flurx = "0.11"
bevy_flurx_ipc = "0.4.0" bevy_flurx_ipc = "0.4.0"
# (run `cargo tree | grep wry` and use the version you see for bevy_webview_wry) # (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] [target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1.0", features = ["rt"] } tokio = { version = "1.0", features = ["rt"] }
@@ -98,3 +100,4 @@ console_error_panic_hook = "0.1"
[build-dependencies] [build-dependencies]
embed-resource = "1" embed-resource = "1"
base-map = { path = "../base-map" }

View File

@@ -13,7 +13,8 @@ use crate::core::{ActionsPlugin, SystemManagerPlugin};
use crate::core::system_manager::SystemManager; use crate::core::system_manager::SystemManager;
use crate::ui::{LoadingPlugin, MenuPlugin, GpsMapPlugin}; use crate::ui::{LoadingPlugin, MenuPlugin, GpsMapPlugin};
use crate::services::GpsServicePlugin; 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")] #[cfg(target_arch = "wasm32")]
use systems::GeoPlugin; use systems::GeoPlugin;
@@ -39,6 +40,42 @@ fn initialize_vessel_systems(mut system_manager: ResMut<SystemManager>) {
} }
} }
/// Update compass gauge with real GPS heading data
fn update_compass_heading(
gps_map_state: Res<GpsMapState>,
mut compass_query: Query<&mut Text, With<CompassGauge>>,
) {
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<GpsMapState>,
mut speed_query: Query<&mut Text, With<SpeedGauge>>,
) {
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<GpsMapState>,
vessel_data: ResMut<VesselData>,
time: Res<Time>,
) {
// 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 { impl Plugin for GamePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_state::<GameState>().add_plugins(( app.init_state::<GameState>().add_plugins((
@@ -51,7 +88,12 @@ impl Plugin for GamePlugin {
PlayerPlugin, 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")] #[cfg(target_arch = "wasm32")]
{ {

View File

@@ -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<DebugLevel> 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<PerformanceMetrics>,
pub debug_data: HashMap<String, serde_json::Value>,
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<serde_json::Value>) {
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<f64>, speed: Option<f64>) {
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<DebugService>,
diagnostics: Res<bevy::diagnostic::DiagnosticsStore>,
) {
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);
};
}

View File

@@ -1,6 +1,11 @@
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpsData { pub struct GpsData {
pub latitude: f64, pub latitude: f64,
@@ -12,36 +17,80 @@ pub struct GpsData {
pub timestamp: f64, pub timestamp: f64,
} }
#[derive(Resource, Default)] #[derive(Resource)]
pub struct GpsService { pub struct GpsService {
pub current_position: Option<GpsData>, pub current_position: Option<GpsData>,
pub is_enabled: bool, pub is_enabled: bool,
pub last_update: f64, pub last_update: f64,
#[cfg(not(target_arch = "wasm32"))]
pub gpyes_provider: Option<GpyesProvider>,
#[cfg(not(target_arch = "wasm32"))]
pub gps_receiver: Option<mpsc::Receiver<GpsData>>,
}
impl Default for GpsService {
fn default() -> Self {
Self::new()
}
} }
impl GpsService { impl GpsService {
pub fn new() -> Self { pub fn new() -> Self {
Self { GpsService {
current_position: None, current_position: None,
is_enabled: false, is_enabled: false,
last_update: 0.0, 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) { pub fn enable(&mut self) {
self.is_enabled = true; self.is_enabled = true;
info!("GPS service enabled"); 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) { pub fn disable(&mut self) {
self.is_enabled = false; self.is_enabled = false;
info!("GPS service disabled"); 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) { pub fn update_position(&mut self, gps_data: GpsData) {
self.current_position = Some(gps_data.clone()); self.current_position = Some(gps_data.clone());
self.last_update = gps_data.timestamp; 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> { pub fn get_current_position(&self) -> Option<&GpsData> {
@@ -49,8 +98,7 @@ impl GpsService {
} }
} }
// Native GPS implementation - Mock implementation for demonstration // Native GPS implementation using GPYes device
// TODO: Replace with real GPS hardware access (e.g., using gpsd, CoreLocation, etc.)
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn start_native_gps_tracking(mut gps_service: ResMut<GpsService>, time: Res<Time>) { pub fn start_native_gps_tracking(mut gps_service: ResMut<GpsService>, time: Res<Time>) {
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -59,90 +107,69 @@ pub fn start_native_gps_tracking(mut gps_service: ResMut<GpsService>, time: Res<
return; return;
} }
// Mock GPS data that simulates realistic movement // Try to receive GPS data from GPYes provider
// In a real implementation, this would read from GPS hardware 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() let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_secs_f64(); .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 { if timestamp - gps_service.last_update < 2.0 {
return; return;
} }
warn!("Using mock GPS data - no hardware provider available");
// Simulate GPS coordinates around Monaco with realistic movement // Simulate GPS coordinates around Monaco with realistic movement
let base_lat = 43.7384; let base_lat = 43.7384;
let base_lon = 7.4246; let base_lon = 7.4246;
let time_factor = time.elapsed_secs() * 0.1;
// Simulate a boat moving in a realistic pattern // Create some movement pattern
let lat_offset = (time_factor.sin() * 0.002) as f64; let time_offset = (timestamp / 10.0).sin() * 0.001;
let lon_offset = (time_factor.cos() * 0.003) as f64; let lat_offset = (timestamp / 15.0).cos() * 0.0005;
let gps_data = GpsData { let mock_gps_data = GpsData {
latitude: base_lat + lat_offset, latitude: base_lat + time_offset,
longitude: base_lon + lon_offset, longitude: base_lon + lat_offset,
altitude: Some(0.0), // Sea level altitude: Some(10.0 + (timestamp / 20.0).sin() * 5.0),
accuracy: Some(3.0), // 3 meter accuracy accuracy: Some(3.0),
heading: Some(((time_factor * 20.0) % 360.0) as f64), heading: Some(((timestamp / 30.0) * 57.2958) % 360.0), // Convert to degrees
speed: Some(5.2), // 5.2 knots speed: Some(5.0 + (timestamp / 25.0).sin() * 2.0), // 3-7 knots
timestamp, timestamp,
}; };
gps_service.update_position(gps_data); gps_service.update_position(mock_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<GpsService>, time: Res<Time>) {
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);
} }
// Bevy plugin for GPS service
pub struct GpsServicePlugin; pub struct GpsServicePlugin;
impl Plugin for GpsServicePlugin { impl Plugin for GpsServicePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<GpsService>() app.init_resource::<GpsService>();
.add_systems(Update, (
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
start_native_gps_tracking, {
#[cfg(target_arch = "wasm32")] app.add_systems(Update, start_native_gps_tracking);
start_web_gps_tracking, }
));
} }
} }
@@ -150,88 +177,47 @@ impl Plugin for GpsServicePlugin {
mod tests { mod tests {
use super::*; use super::*;
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};
#[test] #[test]
fn test_gps_service_initialization() { fn test_gps_service_creation() {
let mut gps_service = GpsService::new(); let service = GpsService::new();
assert!(!gps_service.is_enabled); assert!(!service.is_enabled);
assert!(gps_service.current_position.is_none()); assert!(service.current_position.is_none());
assert_eq!(service.last_update, 0.0);
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));
} }
#[test] #[test]
fn test_gps_service_enable_disable() { fn test_gps_service_enable_disable() {
let mut gps_service = GpsService::new(); let mut service = GpsService::new();
// Test initial state service.enable();
assert!(!gps_service.is_enabled); assert!(service.is_enabled);
// Test enable service.disable();
gps_service.enable(); assert!(!service.is_enabled);
assert!(gps_service.is_enabled); }
// Test disable #[test]
gps_service.disable(); fn test_gps_data_update() {
assert!(!gps_service.is_enabled); 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));
} }
} }

View File

@@ -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<f64>,
pub longitude: Option<f64>,
pub altitude: Option<f64>,
pub speed: Option<f64>,
pub heading: Option<f64>, // Course over ground in degrees
pub timestamp: Option<String>,
pub fix_quality: Option<u8>,
pub satellites: Option<u8>,
pub hdop: Option<f64>, // 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<EnhancedLocationData> 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<EnhancedLocationData> {
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<EnhancedLocationData> {
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::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[3] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
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::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[5] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
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::<u8>() {
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::<u8>() {
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::<f64>() {
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::<f64>() {
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<EnhancedLocationData> {
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::<f64>() {
let degrees = (lat_raw / 100.0).floor();
let minutes = lat_raw - (degrees * 100.0);
let mut latitude = degrees + (minutes / 60.0);
if parts[4] == "S" {
latitude = -latitude;
}
location.latitude = Some(latitude);
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::<f64>() {
let degrees = (lon_raw / 100.0).floor();
let minutes = lon_raw - (degrees * 100.0);
let mut longitude = degrees + (minutes / 60.0);
if parts[6] == "W" {
longitude = -longitude;
}
location.longitude = Some(longitude);
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::<f64>() {
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::<f64>() {
location.heading = Some(course);
if self.debug_enabled {
debug!("[GPS_DEBUG] Parsed course: {:.1}°", course);
}
}
}
Some(location)
}
fn parse_gpvtg(&self, parts: &[&str]) -> Option<EnhancedLocationData> {
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::<f64>() {
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::<f64>() {
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<String>,
baud_rate: u32,
parser: EnhancedGnssParser,
is_running: Arc<Mutex<bool>>,
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<String>) -> 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<mpsc::Receiver<GpsData>, Box<dyn std::error::Error + Send + Sync>> {
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);
}
}

View File

@@ -1,3 +1,6 @@
pub mod gps_service; pub mod gps_service;
#[cfg(not(target_arch = "wasm32"))]
pub mod gpyes_provider;
pub use gps_service::*; pub use gps_service::*;

View File

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

BIN
yachtpit-x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB