mirror of
https://github.com/seemueller-io/yachtpit.git
synced 2025-09-08 22:46:45 +00:00
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:
14
.aiignore
14
.aiignore
@@ -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
1
.gitignore
vendored
@@ -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
23
Cargo.lock
generated
@@ -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",
|
||||||
|
@@ -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">
|
||||||
|
@@ -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}*/}
|
||||||
|
@@ -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 />
|
||||||
|
77
crates/base-map/map/src/user-location-marker.tsx
Normal file
77
crates/base-map/map/src/user-location-marker.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserLocationMarker
|
||||||
|
* • size – overall diameter in px (default 24)
|
||||||
|
* • color – dot / ring colour (default #1E90FF ⟵ system‑blue)
|
||||||
|
* • pulse – adds a subtle accuracy‑halo animation when true
|
||||||
|
*/
|
||||||
|
function UserLocationMarker({
|
||||||
|
size = 24,
|
||||||
|
color = '#1E90FF',
|
||||||
|
pulse = false
|
||||||
|
}) {
|
||||||
|
// stroke width scales with size so the ring stays proportionate
|
||||||
|
const strokeWidth = size * 0.083; // ≈ 2px when size = 24
|
||||||
|
|
||||||
|
// keyframes are injected once per page‑load if pulse is ever enabled
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!pulse || document.getElementById('ulm‑pulse‑kf')) return;
|
||||||
|
const styleTag = document.createElement('style');
|
||||||
|
styleTag.id = 'ulm‑pulse‑kf';
|
||||||
|
styleTag.textContent = `
|
||||||
|
@keyframes ulm‑pulse {
|
||||||
|
0% { r: 0; opacity: .6; }
|
||||||
|
70% { r: 12px; opacity: 0; }
|
||||||
|
100% { r: 12px; opacity: 0; }
|
||||||
|
}`;
|
||||||
|
document.head.appendChild(styleTag);
|
||||||
|
}, [pulse]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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: 'ulm‑pulse 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);
|
@@ -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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
|
564
crates/datalink-provider/src/gpyes/mod.rs
Normal file
564
crates/datalink-provider/src/gpyes/mod.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
16
crates/hardware/Cargo.toml
Normal file
16
crates/hardware/Cargo.toml
Normal 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
366
crates/hardware/README.md
Normal 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
338
crates/hardware/src/bus.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
430
crates/hardware/src/device.rs
Normal file
430
crates/hardware/src/device.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
561
crates/hardware/src/discovery_protocol.rs
Normal file
561
crates/hardware/src/discovery_protocol.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
72
crates/hardware/src/error.rs
Normal file
72
crates/hardware/src/error.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
534
crates/hardware/src/gps_device.rs
Normal file
534
crates/hardware/src/gps_device.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
27
crates/hardware/src/lib.rs
Normal file
27
crates/hardware/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@@ -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
|
||||||
};
|
};
|
||||||
|
@@ -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"));
|
||||||
}
|
}
|
||||||
|
@@ -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" }
|
||||||
|
@@ -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")]
|
||||||
{
|
{
|
||||||
|
366
crates/yachtpit/src/services/debug_service.rs
Normal file
366
crates/yachtpit/src/services/debug_service.rs
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
603
crates/yachtpit/src/services/gpyes_provider.rs
Normal file
603
crates/yachtpit/src/services/gpyes_provider.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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::*;
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
BIN
yachtpit-x.png
Normal file
BIN
yachtpit-x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user