Browse Source

feat: add external GPS source support for tethered devices

pull/877/head
blackboxone 8 months ago
parent
commit
ef88d50093
  1. 80
      gps-bridge-service.js
  2. 2
      packages/web/package.json
  3. 2
      packages/web/src/components/Sidebar.tsx
  4. 90
      packages/web/src/core/hooks/useGPS.ts
  5. 16
      packages/web/src/core/logger.ts
  6. 195
      packages/web/src/core/services/gpsService.ts
  7. 251
      packages/web/src/pages/GPS/index.tsx
  8. 8
      packages/web/src/routes.tsx

80
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');
});

2
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",

2
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"),

90
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<GPSPosition | null>(null);
const [error, setError] = useState<string | null>(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
};
}

16
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);
}
};

195
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<GeolocationPosition | null>> = [];
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<GeolocationPosition | null> {
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<GeolocationPosition> {
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<GeolocationPosition | null> {
// 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();

251
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 (
<PageLayout
label="GPS Status"
leftBar={<Sidebar />}
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'
},
]}
>
<div className="p-6 max-w-4xl mx-auto overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Current Position */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div className="flex items-center gap-3 mb-4">
<Satellite className="w-6 h-6 text-blue-600" />
<h2 className="text-lg font-semibold">Current Position</h2>
</div>
{loading ? (
<div className="text-gray-500">Loading GPS data...</div>
) : error ? (
<div className="text-red-500">Error: {error}</div>
) : position ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Latitude:</span>
<span className="font-mono">{position.latitude.toFixed(6)}°</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Longitude:</span>
<span className="font-mono">{position.longitude.toFixed(6)}°</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Accuracy:</span>
<span className="font-mono">{position.accuracy.toFixed(1)}m</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Last Update:</span>
<span className="text-sm">{new Date(position.timestamp).toLocaleTimeString()}</span>
</div>
</div>
) : (
<div className="text-gray-500">No GPS data available</div>
)}
</div>
{/* GPS Sources */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div className="flex items-center gap-3 mb-4">
<Wifi className="w-6 h-6 text-green-600" />
<h2 className="text-lg font-semibold">GPS Sources</h2>
</div>
{!androidConnected && !iosConnected && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm">
<div className="font-medium mb-1">External GPS Bridge</div>
<div className="text-gray-600 dark:text-gray-400">
To use GPS from a tethered device, run:
<code className="block mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">node gps-bridge-service.js</code>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${browserConnected && useBrowser ? 'bg-green-500' : 'bg-gray-400'}`}></div>
<Smartphone className="w-5 h-5 text-gray-600" />
<div>
<div className="font-medium">Browser Geolocation</div>
<div className="text-sm text-gray-500">Primary GPS source</div>
</div>
</div>
<Switch checked={useBrowser} onCheckedChange={handleBrowserToggle} />
</div>
{androidConnected && (
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${androidConnected && useAndroid ? 'bg-green-500' : 'bg-gray-400'}`}></div>
<Smartphone className="w-5 h-5 text-gray-600" />
<div>
<div className="font-medium">Android Device (ADB)</div>
<div className="text-sm text-gray-500">Port 8080 - Tethered via USB</div>
</div>
</div>
<Switch checked={useAndroid} onCheckedChange={handleAndroidToggle} />
</div>
)}
{iosConnected && (
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${iosConnected && useIOS ? 'bg-green-500' : 'bg-gray-400'}`}></div>
<Smartphone className="w-5 h-5 text-gray-600" />
<div>
<div className="font-medium">iOS Device</div>
<div className="text-sm text-gray-500">Port 8081 - Tethered via USB</div>
</div>
</div>
<Switch checked={useIOS} onCheckedChange={handleIOSToggle} />
</div>
)}
</div>
</div>
{/* Meshtastic Integration */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div className="flex items-center gap-3 mb-4">
<Send className="w-6 h-6 text-purple-600" />
<h2 className="text-lg font-semibold">Meshtastic Integration</h2>
</div>
<div className="space-y-3">
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="font-medium">Auto Position Broadcast</div>
<div className="text-sm text-gray-500">Sends GPS to mesh every 30 seconds</div>
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="font-medium">Manual Position Send</div>
<div className="text-sm text-gray-500">Click 'Send to Device' to broadcast now</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<div className="space-y-3">
<button
onClick={() => {
if (position) {
navigator.clipboard.writeText(`${position.latitude}, ${position.longitude}`);
toast({ title: 'Copied!', description: 'Coordinates copied to clipboard' });
}
}}
disabled={!position}
className="w-full p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50 text-left"
>
<div className="font-medium">Copy Coordinates</div>
<div className="text-sm text-gray-500">Copy lat/lng to clipboard</div>
</button>
<button
onClick={() => position && window.open(`https://www.google.com/maps?q=${position.latitude},${position.longitude}`, '_blank')}
disabled={!position}
className="w-full p-3 bg-green-50 dark:bg-green-900/20 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 disabled:opacity-50 text-left"
>
<div className="font-medium">Google Maps</div>
<div className="text-sm text-gray-500">View in Google Maps</div>
</button>
<button
onClick={() => position && window.open(`https://www.openstreetmap.org/?mlat=${position.latitude}&mlon=${position.longitude}&zoom=15`, '_blank')}
disabled={!position}
className="w-full p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg hover:bg-orange-100 dark:hover:bg-orange-900/30 disabled:opacity-50 text-left"
>
<div className="font-medium">OpenStreetMap</div>
<div className="text-sm text-gray-500">View in OSM</div>
</button>
</div>
</div>
</div>
</div>
</PageLayout>
);
}

8
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,

Loading…
Cancel
Save