diff --git a/gps-bridge-service.js b/gps-bridge-service.js new file mode 100644 index 00000000..807c3324 --- /dev/null +++ b/gps-bridge-service.js @@ -0,0 +1,80 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import http from 'http'; + +const execAsync = promisify(exec); + +let lastPosition = null; +let isEnabled = true; // Can be controlled via /control endpoint + +async function getAndroidGPS() { + if (!isEnabled) { + lastPosition = null; + return null; + } + + try { + const { stdout } = await execAsync('adb shell dumpsys location'); + const match = stdout.match(/last location=Location\[\w+\s+([\-\d.]+),([\-\d.]+)\s+hAcc=([\d.]+)/i); + + if (match) { + lastPosition = { + coords: { + latitude: parseFloat(match[1]), + longitude: parseFloat(match[2]), + accuracy: parseFloat(match[3]) || 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null + }, + timestamp: Date.now() + }; + console.log('[GPS Bridge] GPS data:', lastPosition.coords.latitude, lastPosition.coords.longitude); + } + } catch (error) { + console.error('[GPS Bridge] Error reading GPS:', error.message); + lastPosition = null; // Clear cache when device disconnected + } + return lastPosition; +} + +const server = http.createServer(async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + if (req.url === '/gps') { + const position = await getAndroidGPS(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(position || { coords: null })); + } else if (req.url.startsWith('/control')) { + const url = new URL(req.url, `http://${req.headers.host}`); + const enabled = url.searchParams.get('enabled'); + if (enabled !== null) { + isEnabled = enabled === 'true'; + console.log(`[GPS Bridge] ${isEnabled ? 'Enabled' : 'Disabled'}`); + if (!isEnabled) { + lastPosition = null; + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ enabled: isEnabled })); + } else { + res.writeHead(404); + res.end(); + } +}); + +setInterval(getAndroidGPS, 30000); // Poll every 30 seconds + +server.listen(8080, () => { + console.log('[GPS Bridge] Running on http://localhost:8080'); + console.log('[GPS Bridge] Endpoint: http://localhost:8080/gps'); +}); diff --git a/packages/web/package.json b/packages/web/package.json index 128b45d5..5b1f7b57 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,6 +19,7 @@ "check": "biome check src/", "check:fix": "biome check --write src/", "dev": "vite", + "dev:web-only": "vite", "test": "vitest", "ts:check": "bun run tsc --noEmit", "preview": "vite preview", @@ -99,6 +100,7 @@ "@types/w3c-web-serial": "^1.0.8", "@vitejs/plugin-react": "^5.0.4", "autoprefixer": "^10.4.21", + "concurrently": "^8.2.2", "gzipper": "^8.2.1", "happy-dom": "^19.0.2", "simple-git-hooks": "^2.13.1", diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index 8cc117d8..f7cd4159 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -16,6 +16,7 @@ import { type LucideIcon, MapIcon, MessageSquareIcon, + Satellite, SettingsIcon, UsersIcon, } from "lucide-react"; @@ -106,6 +107,7 @@ export const Sidebar = ({ children }: SidebarProps) => { page: "messages", count: numUnread ? numUnread : undefined, }, + { name: "GPS", icon: Satellite, page: "gps" }, { name: t("navigation.map"), icon: MapIcon, page: "map" }, { name: t("navigation.config"), diff --git a/packages/web/src/core/hooks/useGPS.ts b/packages/web/src/core/hooks/useGPS.ts new file mode 100644 index 00000000..22ef032c --- /dev/null +++ b/packages/web/src/core/hooks/useGPS.ts @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react'; +import { gpsService } from '../services/gpsService'; + +export interface GPSPosition { + latitude: number; + longitude: number; + accuracy: number; + timestamp: number; +} + +export function useGPS() { + const [position, setPosition] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let mounted = true; + + const updatePosition = () => { + const lastKnown = gpsService.getLastKnownPosition(); + if (lastKnown && mounted) { + setPosition({ + latitude: lastKnown.coords.latitude, + longitude: lastKnown.coords.longitude, + accuracy: lastKnown.coords.accuracy, + timestamp: lastKnown.timestamp + }); + setError(null); + setLoading(false); + } + }; + + // Check for position immediately + updatePosition(); + + // Try to get current position + gpsService.getCurrentPosition() + .then((pos) => { + if (mounted) { + setPosition({ + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + timestamp: pos.timestamp + }); + setError(null); + setLoading(false); + } + }) + .catch((err) => { + if (mounted) { + setError(err.message); + setLoading(false); + } + }); + + // Update position periodically + const interval = setInterval(updatePosition, 30000); + + return () => { + mounted = false; + clearInterval(interval); + }; + }, []); + + const refreshPosition = async () => { + setLoading(true); + try { + const pos = await gpsService.getCurrentPosition(); + setPosition({ + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + timestamp: pos.timestamp + }); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'GPS error'); + } finally { + setLoading(false); + } + }; + + return { + position, + error, + loading, + refreshPosition + }; +} \ No newline at end of file diff --git a/packages/web/src/core/logger.ts b/packages/web/src/core/logger.ts new file mode 100644 index 00000000..fe641598 --- /dev/null +++ b/packages/web/src/core/logger.ts @@ -0,0 +1,16 @@ +const isDev = import.meta.env.DEV; + +export const logger = { + debug: (...args: any[]) => { + if (isDev) console.debug(...args); + }, + info: (...args: any[]) => { + if (isDev) console.info(...args); + }, + warn: (...args: any[]) => { + console.warn(...args); + }, + error: (...args: any[]) => { + console.error(...args); + } +}; \ No newline at end of file diff --git a/packages/web/src/core/services/gpsService.ts b/packages/web/src/core/services/gpsService.ts new file mode 100644 index 00000000..ed95ca77 --- /dev/null +++ b/packages/web/src/core/services/gpsService.ts @@ -0,0 +1,195 @@ +import { logger } from "../logger.ts"; + +// GPS Service for automatic tethered device detection +export class GPSService { + private position: GeolocationPosition | null = null; + private watchId: number | null = null; + private fallbackSources: Array<() => Promise> = []; + private isDev = import.meta.env.DEV; + + constructor() { + if (this.isDev) { + this.setupFallbackSources(); + } + this.startGPSWatch(); + } + + private setupFallbackSources() { + // Only setup fallback sources in development + if (!this.isDev) return; + + // ADB GPS Bridge (Android devices) + this.fallbackSources.push(async () => { + // Check if Android GPS is enabled + if (localStorage.getItem('gps-android') === 'false') return null; + + try { + const response = await fetch('http://localhost:8080/gps', { + signal: AbortSignal.timeout(2000) + }); + const data = await response.json(); + if (data.coords) { + logger.debug('GPS: Using ADB bridge'); + return { + coords: { + latitude: data.coords.latitude, + longitude: data.coords.longitude, + accuracy: data.coords.accuracy || 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null + }, + timestamp: Date.now() + } as GeolocationPosition; + } + } catch { + // GPS bridge not available - silent fail + } + return null; + }); + + // iOS/iPad via libimobiledevice (if available) + this.fallbackSources.push(async () => { + // Check if iOS GPS is enabled + if (localStorage.getItem('gps-ios') === 'false') return null; + + try { + const response = await fetch('http://localhost:8081/ios-gps', { + signal: AbortSignal.timeout(2000) + }); + const data = await response.json(); + if (data.coords) { + logger.debug('GPS: Using iOS bridge'); + return { + coords: { + latitude: data.coords.latitude, + longitude: data.coords.longitude, + accuracy: data.coords.accuracy || 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null + }, + timestamp: Date.now() + } as GeolocationPosition; + } + } catch { + // iOS GPS not available - silent fail + } + return null; + }); + } + + private async tryFallbackSources(): Promise { + for (const source of this.fallbackSources) { + const position = await source(); + if (position) { + + return position; + } + } + return null; + } + + private startGPSWatch() { + // Try tethered GPS first (more accurate) + this.tryFallbackLoop(); + + // Also start browser geolocation as backup + if (navigator.geolocation) { + this.watchId = navigator.geolocation.watchPosition( + (position) => { + // Check if browser GPS is enabled + if (localStorage.getItem('gps-browser') === 'false') return; + + // Only use browser GPS if no tethered GPS available + if (!this.position) { + this.position = position; + logger.debug('GPS: Using browser geolocation'); + } + }, + () => { + logger.debug('GPS: Browser geolocation failed'); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 30000 + } + ); + } + } + + private async tryFallbackLoop() { + // Check immediately + const initialPosition = await this.tryFallbackSources(); + if (initialPosition) { + this.position = initialPosition; + } + + // Then check every 5 seconds + setInterval(async () => { + const fallbackPosition = await this.tryFallbackSources(); + if (fallbackPosition) { + this.position = fallbackPosition; + } + }, 5000); + } + + public getCurrentPosition(): Promise { + return new Promise(async (resolve, reject) => { + if (this.position) { + resolve(this.position); + return; + } + + // Try browser geolocation + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + resolve, + async (error) => { + // Try fallback sources + const fallbackPosition = await this.tryFallbackSources(); + if (fallbackPosition) { + resolve(fallbackPosition); + } else { + reject(error); + } + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 30000 + } + ); + } else { + // Try fallback sources + const fallbackPosition = await this.tryFallbackSources(); + if (fallbackPosition) { + resolve(fallbackPosition); + } else { + reject(new Error('No GPS sources available')); + } + } + }); + } + + public getLastKnownPosition(): GeolocationPosition | null { + return this.position; + } + + public async getTetheredPosition(): Promise { + // Only try tethered sources, not browser geolocation + return await this.tryFallbackSources(); + } + + public destroy() { + if (this.watchId !== null) { + navigator.geolocation.clearWatch(this.watchId); + } + } +} + +// Global GPS service instance +export const gpsService = new GPSService(); \ No newline at end of file diff --git a/packages/web/src/pages/GPS/index.tsx b/packages/web/src/pages/GPS/index.tsx new file mode 100644 index 00000000..200301b6 --- /dev/null +++ b/packages/web/src/pages/GPS/index.tsx @@ -0,0 +1,251 @@ +import { useGPS } from '@core/hooks/useGPS'; +import { PageLayout } from '@components/PageLayout.tsx'; +import { Sidebar } from '@components/Sidebar.tsx'; +import { Switch } from '@components/UI/Switch.tsx'; +import { toast } from '@core/hooks/useToast'; +import { Satellite, Smartphone, Wifi, RefreshCw, Send } from 'lucide-react'; +import { useState, useEffect } from 'react'; + +export default function GPSPage() { + const { position, error, loading, refreshPosition } = useGPS(); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isSending, setIsSending] = useState(false); + const [useBrowser, setUseBrowser] = useState(() => localStorage.getItem('gps-browser') !== 'false'); + const [useAndroid, setUseAndroid] = useState(() => localStorage.getItem('gps-android') === 'true'); + const [useIOS, setUseIOS] = useState(() => localStorage.getItem('gps-ios') !== 'false'); + const [browserConnected, setBrowserConnected] = useState(false); + const [androidConnected, setAndroidConnected] = useState(false); + const [iosConnected, setIOSConnected] = useState(false); + + // Save to localStorage when changed + const handleBrowserToggle = (checked: boolean) => { + setUseBrowser(checked); + localStorage.setItem('gps-browser', String(checked)); + }; + + const handleAndroidToggle = (checked: boolean) => { + setUseAndroid(checked); + localStorage.setItem('gps-android', String(checked)); + }; + + const handleIOSToggle = (checked: boolean) => { + setUseIOS(checked); + localStorage.setItem('gps-ios', String(checked)); + }; + + // Check GPS source connectivity + useEffect(() => { + const checkSources = async () => { + // Check browser GPS + if (navigator.geolocation) { + setBrowserConnected(true); + } + + // Check Android GPS + try { + const response = await fetch('http://localhost:8080/gps'); + const data = await response.json(); + setAndroidConnected(!!data.coords); + } catch { + setAndroidConnected(false); + } + + // Check iOS GPS + try { + const response = await fetch('http://localhost:8081/ios-gps'); + const data = await response.json(); + setIOSConnected(!!data.coords); + } catch { + setIOSConnected(false); + } + }; + checkSources(); + const interval = setInterval(checkSources, 5000); + return () => clearInterval(interval); + }, []); + + const handleRefresh = async () => { + setIsRefreshing(true); + await refreshPosition(); + setIsRefreshing(false); + }; + + return ( + } + actions={[ + { + key: 'refresh', + icon: RefreshCw, + iconClasses: isRefreshing ? 'animate-spin' : '', + onClick: handleRefresh, + disabled: isRefreshing, + label: 'Refresh', + className: 'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50' + }, + + ]} + > +
+ +
+ {/* Current Position */} +
+
+ +

Current Position

+
+ + {loading ? ( +
Loading GPS data...
+ ) : error ? ( +
Error: {error}
+ ) : position ? ( +
+
+ Latitude: + {position.latitude.toFixed(6)}° +
+
+ Longitude: + {position.longitude.toFixed(6)}° +
+
+ Accuracy: + {position.accuracy.toFixed(1)}m +
+
+ Last Update: + {new Date(position.timestamp).toLocaleTimeString()} +
+
+ ) : ( +
No GPS data available
+ )} +
+ + {/* GPS Sources */} +
+
+ +

GPS Sources

+
+ + {!androidConnected && !iosConnected && ( +
+
External GPS Bridge
+
+ To use GPS from a tethered device, run: + node gps-bridge-service.js +
+
+ )} + +
+
+
+
+ +
+
Browser Geolocation
+
Primary GPS source
+
+
+ +
+ + {androidConnected && ( +
+
+
+ +
+
Android Device (ADB)
+
Port 8080 - Tethered via USB
+
+
+ +
+ )} + + {iosConnected && ( +
+
+
+ +
+
iOS Device
+
Port 8081 - Tethered via USB
+
+
+ +
+ )} +
+
+ + {/* Meshtastic Integration */} +
+
+ +

Meshtastic Integration

+
+ +
+
+
Auto Position Broadcast
+
Sends GPS to mesh every 30 seconds
+
+ +
+
Manual Position Send
+
Click 'Send to Device' to broadcast now
+
+
+
+ + {/* Quick Actions */} +
+

Quick Actions

+ +
+ + + + + +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx index e134c4b7..2cf5d672 100644 --- a/packages/web/src/routes.tsx +++ b/packages/web/src/routes.tsx @@ -2,6 +2,7 @@ import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import type { useAppStore, useMessageStore } from "@core/stores"; import ConfigPage from "@pages/Config/index.tsx"; import { Dashboard } from "@pages/Dashboard/index.tsx"; +import GPSPage from "@pages/GPS/index.tsx"; import MapPage from "@pages/Map/index.tsx"; import MessagesPage from "@pages/Messages.tsx"; import NodesPage from "@pages/Nodes/index.tsx"; @@ -68,6 +69,12 @@ export const messagesWithParamsRoute = createRoute({ }), }); +const gpsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/gps", + component: GPSPage, +}); + const mapRoute = createRoute({ getParentRoute: () => rootRoute, path: "/map", @@ -96,6 +103,7 @@ const routeTree = rootRoute.addChildren([ indexRoute, messagesRoute, messagesWithParamsRoute, + gpsRoute, mapRoute, configRoute, nodesRoute,