diff --git a/.github/workflows/eslint_lint.yml b/.github/workflows/eslint_lint.yml
new file mode 100644
index 0000000..4af168b
--- /dev/null
+++ b/.github/workflows/eslint_lint.yml
@@ -0,0 +1,41 @@
+name: Run ESLint Linter
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '22'
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.npm
+ frontend/node_modules
+ key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-node-
+
+ - name: Install dependencies
+ working-directory: frontend
+ run: |
+ # Generate a fresh package-lock.json and install dependencies
+ npm install --package-lock-only
+ npm install
+
+ - name: Run ESLint
+ working-directory: frontend
+ run: npm run lint
\ No newline at end of file
diff --git a/.github/workflows/pytest_tests.yml b/.github/workflows/pytest_tests.yml
new file mode 100644
index 0000000..9ca0a2f
--- /dev/null
+++ b/.github/workflows/pytest_tests.yml
@@ -0,0 +1,52 @@
+name: Run Pytest Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3
+
+ - name: Set up Python 3.13
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.13'
+
+ - name: Install Qt dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-icccm4 \
+ libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
+ libxcb-xinerama0 libxcb-xfixes0 x11-utils xvfb
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.cache/pypoetry
+ ~/.cache/pip
+ key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-poetry-
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install poetry
+ poetry install --with dev
+
+ - name: Run tests
+ env:
+ QT_QPA_PLATFORM: offscreen
+ DISPLAY: ":99.0"
+ run: |
+ Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
+ sleep 3
+ poetry run pytest
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..0f280c9
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,103 @@
+name: Create Release
+
+on:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+
+jobs:
+ # First job to check if we should run the release
+ check_release:
+ runs-on: ubuntu-latest
+ if: |
+ github.event.pull_request.merged == true &&
+ (startsWith(github.event.pull_request.head.ref, 'hotfix/v') ||
+ startsWith(github.event.pull_request.head.ref, 'release/v'))
+ outputs:
+ version: ${{ steps.get_version.outputs.version }}
+ steps:
+ - name: Extract version from branch name
+ id: get_version
+ run: |
+ BRANCH=${{ github.event.pull_request.head.ref }}
+ VERSION=${BRANCH#*/v} # Remove prefix up to v
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+
+ # Build job that runs on each OS
+ build:
+ needs: check_release
+ strategy:
+ matrix:
+ include:
+ - os: windows-latest
+ build_os: windows
+ artifact: RTT-GCS-windows-x64.zip
+ - os: ubuntu-latest
+ build_os: linux
+ artifact: RTT-GCS-linux-x64.zip
+ - os: macos-latest
+ build_os: macos
+ artifact: RTT-GCS-macos-x64.zip
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.13'
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '22'
+
+ - name: Install Python dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install poetry
+ poetry install
+
+ - name: Install Node.js dependencies
+ working-directory: frontend
+ run: |
+ # Generate a fresh package-lock.json and install dependencies
+ npm install --package-lock-only
+ npm install
+
+ - name: Build for ${{ matrix.os }}
+ run: poetry run python scripts/build.py --os ${{ matrix.build_os }}
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v3
+ with:
+ name: ${{ matrix.artifact }}
+ path: dist/${{ matrix.artifact }}
+
+ # Create release after all builds complete
+ create_release:
+ needs: [check_release, build]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v3
+ with:
+ path: dist
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: v${{ needs.check_release.outputs.version }}
+ name: Release v${{ needs.check_release.outputs.version }}
+ files: |
+ dist/RTT-GCS-windows-x64.zip/RTT-GCS-windows-x64.zip
+ dist/RTT-GCS-linux-x64.zip/RTT-GCS-linux-x64.zip
+ dist/RTT-GCS-macos-x64.zip/RTT-GCS-macos-x64.zip
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/ruff_lint.yml b/.github/workflows/ruff_lint.yml
new file mode 100644
index 0000000..c3a4c23
--- /dev/null
+++ b/.github/workflows/ruff_lint.yml
@@ -0,0 +1,40 @@
+name: Run Ruff Linter
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3
+
+ - name: Set up Python 3.13
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.13'
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.cache/pypoetry
+ ~/.cache/pip
+ key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-poetry-
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install poetry
+ poetry install --with dev
+
+ - name: Run ruff linter
+ run: |
+ poetry run ruff check .
\ No newline at end of file
diff --git a/.github/workflows/vitest_tests.yml b/.github/workflows/vitest_tests.yml
new file mode 100644
index 0000000..a012c44
--- /dev/null
+++ b/.github/workflows/vitest_tests.yml
@@ -0,0 +1,41 @@
+name: Run Vitest Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '22'
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.npm
+ frontend/node_modules
+ key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-node-
+
+ - name: Install dependencies
+ working-directory: frontend
+ run: |
+ # Generate a fresh package-lock.json and install dependencies
+ npm install --package-lock-only
+ npm install
+
+ - name: Run tests
+ working-directory: frontend
+ run: npm run test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..83bc5c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,63 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+.pytest_cache/
+.coverage
+htmlcov/
+.env
+.venv
+env/
+venv/
+ENV/
+ruff_cache/
+poetry.lock
+
+# Node/Frontend
+node_modules/
+frontend/dist/
+frontend/build/
+.npm
+*.log
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+package-lock.json
+
+# IDEs and editors
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+Thumbs.db
+
+# Build outputs
+*.spec
+dist/
+build/
+
+# Misc
+*.log
+.DS_Store
+*.db
+*.db-shm
+*.db-wal
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ea1566f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,12 @@
+
+This software is Copyright © 2024 The Regents of the University of California. All Rights Reserved. Permission to copy, modify, and distribute this software and its documentation for educational, research and non-profit purposes, without fee, and without a written agreement is hereby granted, provided that the above copyright notice, this paragraph and the following three paragraphs appear in all copies. Permission to make commercial use of this software may be obtained by contacting:
+
+Office of Innovation and Commercialization
+9500 Gilman Drive, Mail Code 0910
+University of California
+La Jolla, CA 92093-0910
+innovation@ucsd.edu
+
+This software program and documentation are copyrighted by The Regents of the University of California. The software program and documentation are supplied “as is”, without any accompanying services from The Regents. The Regents does not warrant that the operation of the program will be uninterrupted or error-free. The end-user understands that the program was developed for research purposes and is advised not to rely exclusively on the program for any reason.
+
+IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN “AS IS” BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
diff --git a/README.md b/README.md
index 3fd7ac0..c11ef2c 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,118 @@
-## Radio Telemetry Tracker Drone Ground Control Station
\ No newline at end of file
+# Radio Telemetry Tracker Drone Ground Control Station (GCS)
+
+**The Radio Telemetry Tracker Drone Ground Control Station (GCS)** is a desktop application designed to interface with the [**Radio Telemetry Tracker Drone Field Device Software (FDS)**](https://github.com/UCSD-E4E/radio-telemetry-tracker-drone-fds) for wildlife radio tracking. Using this GCS, users can configure radio parameters, send start/stop commands, track GPS data, visualize pings on a map, and control drone operations in real-time or via a built-in simulator.
+
+## Table of Contents
+- [Radio Telemetry Tracker Drone Ground Control Station (GCS)](#radio-telemetry-tracker-drone-ground-control-station-gcs)
+ - [Table of Contents](#table-of-contents)
+ - [Overview](#overview)
+ - [Features](#features)
+ - [System Requirements](#system-requirements)
+ - [Download and Installation](#download-and-installation)
+ - [Prebuilt Releases](#prebuilt-releases)
+ - [Building from Source](#building-from-source)
+ - [Hot-running the app](#hot-running-the-app)
+ - [Troubleshooting](#troubleshooting)
+ - [License](#license)
+
+## Overview
+
+The **GCS** complements the **FDS** software (which runs on the drone payload), enabling a real-time link for controlling and monitoring wildlife telemetry operations. Whether you’re tracking radio-tagged animals in the field or testing the system at a desk, this tool offers a graphical interface for:
+
+1. **Radio Configuration** – set baud rates, TCP/serial modes, retry settings.
+2. **Ping Finder Configuration** – manage scanning frequencies and signal detection parameters.
+3. **GPS Visualization** – see the drone’s location on a map, plus ping detection layers.
+4. **Start/Stop** – control the FDS’s signal scanning process.
+5. **Offline/Simulator** – test or demo the system without drone hardware.
+
+## Features
+
+- **Interactive Map**: Displays live drone position, ping detections, and location estimates.
+- **Telemetry**: Real-time GPS updates, frequency data, and logs from the drone FDS.
+- **Configurable**: Choose between serial or TCP communications.
+- **Simulator**: Built-in simulator for local testing (no physical drone hardware required).
+- **Offline Caching**: Optionally caches map tiles for limited connectivity environments.
+
+## System Requirements
+
+- **Operating System**: Windows 10/11, Ubuntu 22.04+ (or similar), macOS 13+.
+- **Memory**: 8 GB+ recommended.
+- **Python**: 3.13+ if building/running Python backend.
+- **Poetry**: 2.0+ if building/running Python backend.
+- **Node.js**: 22+ if building React/TypeScript locally.
+- **Dependencies**:
+ - PyQt6, PyQt6-WebEngine, requests, pyproj, scipy (for the Python side).
+ - React, Leaflet, TypeScript, etc. (for the frontend).
+
+*(Prebuilt releases may bundle these dependencies, so you don’t need to install them separately.)*
+
+## Download and Installation
+
+### Prebuilt Releases
+
+Precompiled executables are availabe under the [**Releases** tab](https://github.com/UCSD-E4E/radio-telemetry-tracker-drone-gcs/releases). You will typically see three artifacts:
+
+- **Windows**: `RTT-GCS-windows-x64.zip`
+- **Linux**: `RTT-GCS-linux-x64.zip`
+- **macOS**: `RTT-GCS-macos-x64.zip`
+
+Download the one for your platform, unzip it, and run the executable.
+
+### Building from Source
+
+If you want to build from source:
+
+1. **Clone** the repository:
+
+```bash
+git clone https://github.com/UCSD-E4E/radio-telemetry-tracker-drone-gcs.git
+cd radio-telemetry-tracker-drone-gcs
+```
+
+2. **Install dependencies**:
+
+ - **Python side**:
+ ```bash
+ poetry install
+ ```
+
+ - **Node/React side (optional)**:
+ ```bash
+ cd frontend
+ npm install
+ ```
+
+3. **Build** the app:
+ ```bash
+ poetry run rtt-gcs-build
+ ```
+ This generates a `dist/` folder with the executable. Note that PyInstaller only builds for the current platform.
+
+### Hot-running the app
+
+To run the app with minimal build requirements, you can use the following command:
+
+```bash
+poetry run rtt-gcs-dev
+```
+
+This will only build the frontend and run the Python backend.
+
+
+## Troubleshooting
+1. **Connection Fails**
+ - Verify the drone’s FDS is powered and running.
+ - Confirm correct COM port or TCP port settings.
+ - Check logs for “Unable to sync” or “Timeout”.
+2. **No Map Tiles**
+ - If offline, you may need to cache tiles while connected or switch back to online mode.
+ - Check the tile info overlay in GCS for how many tiles are cached.
+3. **Simulator Doesn’t Start**
+ - Make sure you installed Python dependencies (e.g., radio_telemetry_tracker_drone_comms_package).
+ - Check the console logs for errors.
+4. **Crashes or Unresponsive**
+ - Try restarting the GCS, or updating graphics drivers for QWebEngine.
+ - Use the system’s event logs or journalctl (Linux) for deeper info.
+
+## License
+This project is licensed under the terms specified in the [LICENSE](LICENSE) file.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..e9cbcbc
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,71 @@
+import js from '@eslint/js';
+import typescript from '@typescript-eslint/eslint-plugin';
+import typescriptParser from '@typescript-eslint/parser';
+import reactPlugin from 'eslint-plugin-react';
+import reactHooksPlugin from 'eslint-plugin-react-hooks';
+import reactRefreshPlugin from 'eslint-plugin-react-refresh';
+
+export default [
+ {
+ ignores: ['**/dist/**'],
+ },
+ js.configs.recommended,
+ {
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ parser: typescriptParser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ globals: {
+ window: 'readonly',
+ document: 'readonly',
+ setTimeout: 'readonly',
+ clearTimeout: 'readonly',
+ console: 'readonly',
+ __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
+ HTMLImageElement: 'readonly',
+ HTMLDivElement: 'readonly',
+ HTMLSelectElement: 'readonly',
+ HTMLInputElement: 'readonly',
+ MouseEvent: 'readonly',
+ CustomEvent: 'readonly',
+ EventListener: 'readonly',
+ AbortController: 'readonly',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': typescript,
+ react: reactPlugin,
+ 'react-hooks': reactHooksPlugin,
+ 'react-refresh': reactRefreshPlugin,
+ },
+ rules: {
+ ...typescript.configs.recommended.rules,
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ 'react/jsx-uses-react': 'error',
+ 'react/jsx-uses-vars': 'error',
+ 'no-empty': ['error', { allowEmptyCatch: true }],
+ 'no-constant-condition': ['error', { checkLoops: false }],
+ 'no-prototype-builtins': 'off',
+ 'no-control-regex': 'off',
+ 'no-useless-escape': 'off',
+ 'no-fallthrough': 'off',
+ 'no-cond-assign': 'off',
+ 'getter-return': 'off',
+ 'valid-typeof': 'off',
+ 'no-misleading-character-class': 'off',
+ 'no-unused-vars': 'off',
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ { varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
+ ],
+ },
+ },
+];
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..f00d730
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ Radio Telemetry Tracker Drone GCS
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..433c3cf
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "radio-telemetry-tracker-drone-gcs-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint .",
+ "test": "vitest"
+ },
+ "dependencies": {
+ "@heroicons/react": "^2.2.0",
+ "@types/leaflet": "^1.9.15",
+ "@types/localforage": "^0.0.33",
+ "date-fns": "^4.1.0",
+ "leaflet": "^1.9.4",
+ "leaflet.offline": "^3.0.0-rc.4",
+ "localforage": "^1.10.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-leaflet": "^5.0.0",
+ "recoil": "^0.7.7",
+ "tailwindcss": "^3.4.17"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.17.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@types/testing-library__react": "^10.0.1",
+ "@typescript-eslint/eslint-plugin": "^8.19.0",
+ "@typescript-eslint/parser": "^8.19.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.17.0",
+ "eslint-plugin-react": "^7.37.3",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.16",
+ "jsdom": "^26.0.0",
+ "postcss": "^8.4.49",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.6",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..a3670aa
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,17 @@
+import GlobalAppProvider from './context/GlobalAppContext';
+import MainLayout from './MainLayout';
+import FatalErrorModal from './components/FatalErrorModal';
+
+/**
+ * Wrap everything in GlobalAppProvider for both comms and map states.
+ */
+function App() {
+ return (
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/frontend/src/MainLayout.tsx b/frontend/src/MainLayout.tsx
new file mode 100644
index 0000000..6cdb3c6
--- /dev/null
+++ b/frontend/src/MainLayout.tsx
@@ -0,0 +1,84 @@
+import React, { useState } from 'react';
+import DeviceControls from './components/device/DeviceControls';
+import FrequencyManager from './components/manager/FrequencyManager';
+import MapContainer from './components/map/MapContainer';
+import MapOverlayControls from './components/map/MapOverlayControls';
+import {
+ SignalIcon,
+ RadioIcon
+} from '@heroicons/react/24/outline';
+
+const MainLayout: React.FC = () => {
+ const [activeTab, setActiveTab] = useState<'device' | 'map'>('device');
+
+ return (
+
+ {/* Sidebar */}
+
+
+
+
+ Radio Telemetry Tracker
+
+
+ Drone Ground Control Station
+
+
+
+
+ setActiveTab('device')}
+ icon={}
+ label="Device Control"
+ />
+ setActiveTab('map')}
+ icon={}
+ label="Frequencies"
+ />
+
+
+
+ {activeTab === 'device' ? : }
+
+
+
+ {/* Map Container */}
+
+
+
+
+
+ );
+};
+
+interface TabButtonProps {
+ active: boolean;
+ onClick: () => void;
+ icon: React.ReactNode;
+ label: string;
+}
+
+const TabButton: React.FC = ({ active, onClick, icon, label }) => (
+
+);
+
+export default MainLayout;
diff --git a/frontend/src/components/FatalErrorModal.tsx b/frontend/src/components/FatalErrorModal.tsx
new file mode 100644
index 0000000..8c2b69c
--- /dev/null
+++ b/frontend/src/components/FatalErrorModal.tsx
@@ -0,0 +1,42 @@
+import React, { useContext } from 'react';
+import { GlobalAppContext } from '../context/globalAppContextDef';
+
+const FatalErrorModal: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('FatalErrorModal must be used within GlobalAppProvider');
+
+ const { fatalError } = context;
+
+ if (!fatalError) return null;
+
+ return (
+
+
+
+
:(
+
+ Your field device ran into a problem
+
+
+
+
+
+
+ Please follow these steps to recover:
+
+
+ - Retrieve your field device
+ - Power cycle the field device
+ - Close and restart the Ground Control System application
+
+
+ To determine what caused this error, check the logs on the field device software.
+
+
+
+
+
+ );
+};
+
+export default FatalErrorModal;
\ No newline at end of file
diff --git a/frontend/src/components/common/Card.tsx b/frontend/src/components/common/Card.tsx
new file mode 100644
index 0000000..d08ca62
--- /dev/null
+++ b/frontend/src/components/common/Card.tsx
@@ -0,0 +1,22 @@
+import React, { ReactNode } from 'react';
+
+interface CardProps {
+ title?: string;
+ children: ReactNode;
+ className?: string;
+}
+
+const Card: React.FC = ({ title, children, className }) => {
+ return (
+
+ {title && (
+
+
{title}
+
+ )}
+ {children}
+
+ );
+};
+
+export default Card;
diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx
new file mode 100644
index 0000000..9d8bd9b
--- /dev/null
+++ b/frontend/src/components/common/Modal.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+
+interface ModalProps {
+ title?: string;
+ show: boolean;
+ onClose: () => void;
+ children: React.ReactNode;
+ width?: string; // e.g. 'w-96'
+}
+
+const Modal: React.FC = ({
+ title,
+ show,
+ onClose,
+ children,
+ width = 'w-96',
+}) => {
+ if (!show) return null;
+
+ return (
+
+
+
+ {title &&
{title}
}
+
+
+
{children}
+
+
+ );
+};
+
+export default Modal;
diff --git a/frontend/src/components/device/DeviceControls.tsx b/frontend/src/components/device/DeviceControls.tsx
new file mode 100644
index 0000000..6f147d4
--- /dev/null
+++ b/frontend/src/components/device/DeviceControls.tsx
@@ -0,0 +1,70 @@
+import React, { useContext } from 'react';
+import { useSimulatorShortcut } from '../../hooks/useSimulatorShortcut';
+import SimulatorPopup from './cards/SimulatorPopup';
+import DroneStatus from './cards/DroneStatus';
+import Message from './cards/Message';
+import RadioConfig from './cards/RadioConfig';
+import PingFinderConfig from './cards/PingFinderConfig';
+import Start from './cards/Start';
+import Stop from './cards/Stop';
+import Disconnect from './cards/Disconnect';
+import { GlobalAppContext } from '../../context/globalAppContextDef';
+import { GCSState } from '../../context/globalAppTypes';
+
+const DeviceControls: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ const { isSimulatorOpen, setIsSimulatorOpen } = useSimulatorShortcut();
+
+ if (!context) return null;
+ const { gcsState, message, messageType, messageVisible } = context;
+
+ const showRadioConfig = [
+ GCSState.RADIO_CONFIG_INPUT,
+ GCSState.RADIO_CONFIG_WAITING,
+ GCSState.RADIO_CONFIG_TIMEOUT
+ ].includes(gcsState);
+
+ const showPingFinder = [
+ GCSState.PING_FINDER_CONFIG_INPUT,
+ GCSState.PING_FINDER_CONFIG_WAITING,
+ GCSState.PING_FINDER_CONFIG_TIMEOUT
+ ].includes(gcsState);
+
+ const showStart = [
+ GCSState.START_INPUT,
+ GCSState.START_WAITING,
+ GCSState.START_TIMEOUT
+ ].includes(gcsState);
+
+ const showStop = [
+ GCSState.STOP_INPUT,
+ GCSState.STOP_WAITING,
+ GCSState.STOP_TIMEOUT
+ ].includes(gcsState);
+
+ const hideDisconnect = [
+ GCSState.RADIO_CONFIG_INPUT,
+ GCSState.RADIO_CONFIG_WAITING,
+ GCSState.RADIO_CONFIG_TIMEOUT
+ ].includes(gcsState);
+
+ return (
+
+
+ {messageVisible &&
}
+ {showRadioConfig &&
}
+ {showPingFinder &&
}
+ {showStart &&
}
+ {showStop &&
}
+ {!hideDisconnect &&
}
+
+ {/* Hidden Simulator Popup (Ctrl/Cmd + Alt + S to toggle) */}
+
setIsSimulatorOpen(false)}
+ />
+
+ );
+};
+
+export default DeviceControls;
diff --git a/frontend/src/components/device/cards/Disconnect.tsx b/frontend/src/components/device/cards/Disconnect.tsx
new file mode 100644
index 0000000..40eba52
--- /dev/null
+++ b/frontend/src/components/device/cards/Disconnect.tsx
@@ -0,0 +1,67 @@
+import React, { useContext, useState } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import Card from '../../common/Card';
+import { PowerIcon } from '@heroicons/react/24/outline';
+
+const Disconnect: React.FC = () => {
+ const [isDisconnecting, setIsDisconnecting] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const context = useContext(GlobalAppContext);
+ if (!context) return null;
+ const { disconnect } = context;
+
+ const handleDisconnect = async () => {
+ setIsDisconnecting(true);
+ await disconnect();
+ };
+
+ return (
+
+
+ {isDisconnecting ? (
+
+ ) : showConfirm ? (
+
+
+ Are you sure you want to disconnect?
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default Disconnect;
\ No newline at end of file
diff --git a/frontend/src/components/device/cards/DroneStatus.tsx b/frontend/src/components/device/cards/DroneStatus.tsx
new file mode 100644
index 0000000..f2a1caa
--- /dev/null
+++ b/frontend/src/components/device/cards/DroneStatus.tsx
@@ -0,0 +1,160 @@
+import React, { useContext } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import { MapPinIcon, SignalIcon, ClockIcon, GlobeAltIcon } from '@heroicons/react/24/outline';
+import Card from '../../common/Card';
+import { useConnectionQuality } from '../../../hooks/useConnectionQuality';
+
+enum ConnectionQuality {
+ DISCONNECTED = 0,
+ POOR = 1,
+ FAIR = 2,
+ GOOD = 3,
+ EXCELLENT = 4,
+ OPTIMAL = 5
+}
+
+const getConnectionQualityFromState = (quality: number): ConnectionQuality => {
+ switch (quality) {
+ case 5:
+ return ConnectionQuality.OPTIMAL;
+ case 4:
+ return ConnectionQuality.EXCELLENT;
+ case 3:
+ return ConnectionQuality.GOOD;
+ case 2:
+ return ConnectionQuality.FAIR;
+ case 1:
+ return ConnectionQuality.POOR;
+ case 0:
+ return ConnectionQuality.DISCONNECTED;
+ default:
+ return ConnectionQuality.DISCONNECTED;
+ }
+};
+
+const getConnectionQualityColor = (quality: ConnectionQuality) => {
+ switch (quality) {
+ case ConnectionQuality.OPTIMAL:
+ case ConnectionQuality.EXCELLENT:
+ return 'bg-green-500';
+ case ConnectionQuality.GOOD:
+ return 'bg-blue-500';
+ case ConnectionQuality.FAIR:
+ return 'bg-yellow-500';
+ case ConnectionQuality.POOR:
+ return 'bg-red-500';
+ default:
+ return 'bg-gray-300';
+ }
+};
+
+const ConnectionQualityIndicator: React.FC<{ quality: ConnectionQuality }> = ({ quality }) => {
+ const color = getConnectionQualityColor(quality);
+ return (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ );
+};
+
+const DroneStatus: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('Must be inside GlobalAppProvider');
+
+ const { connectionStatus, gpsData, mapRef } = context;
+ const isConnected = connectionStatus === 1;
+ const { connectionQuality, pingTime, gpsFrequency } = useConnectionQuality(gpsData, isConnected);
+ const quality = getConnectionQualityFromState(connectionQuality);
+
+ const handleGoToDrone = () => {
+ if (gpsData && mapRef.current) {
+ mapRef.current.setView([gpsData.lat, gpsData.long], mapRef.current.getZoom());
+ }
+ };
+
+ // Map quality indicators
+ const qualityColors: Record = {
+ 5: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
+ 4: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' },
+ 3: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200' },
+ 2: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
+ 1: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
+ 0: { bg: 'bg-gray-50', text: 'text-gray-700', border: 'border-gray-200' }
+ };
+
+ const qualityText: Record = {
+ 5: 'Optimal Range',
+ 4: 'Excellent Range',
+ 3: 'Good Range',
+ 2: 'Fair Range',
+ 1: 'Poor Range',
+ 0: 'Disconnected'
+ };
+
+ return (
+
+
+ {/* Connection Status */}
+
+
+
+
+
+
+ {qualityText[quality]}
+
+
+
+
+
+
+
+
+
+
+ {/* Stats Grid */}
+
+
+
+
+ Average Ping
+
+
+ {isConnected && gpsData ? `${Math.round(pingTime)}ms` : '--'}
+
+
+
+
+
+ GPS Frequency
+
+
+ {isConnected && gpsData ? `${gpsFrequency.toFixed(1)}Hz` : '--'}
+
+
+
+
+
+ );
+};
+
+export default DroneStatus;
diff --git a/frontend/src/components/device/cards/Message.tsx b/frontend/src/components/device/cards/Message.tsx
new file mode 100644
index 0000000..5c63231
--- /dev/null
+++ b/frontend/src/components/device/cards/Message.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { CheckCircleIcon, ExclamationCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { useContext } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+
+interface MessageProps {
+ message: string;
+ type: 'error' | 'success';
+}
+
+const Message: React.FC = ({ message, type }) => {
+ const context = useContext(GlobalAppContext);
+ if (!context) return null;
+ const { setMessageVisible, messageVisible } = context;
+
+ if (!messageVisible) return null;
+
+ const styles = {
+ error: {
+ bg: 'bg-red-50',
+ border: 'border-red-200',
+ text: 'text-red-700',
+ icon: ,
+ backdrop: 'bg-red-500/5'
+ },
+ success: {
+ bg: 'bg-green-50',
+ border: 'border-green-200',
+ text: 'text-green-700',
+ icon: ,
+ backdrop: 'bg-green-500/5'
+ }
+ };
+
+ const currentStyle = styles[type];
+
+ return (
+
+
+
+ {currentStyle.icon}
+
+
+
+
+
+
+ );
+};
+
+export default Message;
\ No newline at end of file
diff --git a/frontend/src/components/device/cards/PingFinderConfig.tsx b/frontend/src/components/device/cards/PingFinderConfig.tsx
new file mode 100644
index 0000000..2cd2234
--- /dev/null
+++ b/frontend/src/components/device/cards/PingFinderConfig.tsx
@@ -0,0 +1,292 @@
+import React, { useState, useCallback, useContext } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import { GCSState } from '../../../context/globalAppTypes';
+import { PlusIcon, XMarkIcon, AdjustmentsHorizontalIcon, BeakerIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
+import Card from '../../common/Card';
+
+const calculateCenterFrequency = (frequencies: number[], defaultFreq: number) => {
+ if (!frequencies.length) return defaultFreq;
+ return Math.round(frequencies.reduce((a: number, b: number) => a + b, 0) / frequencies.length);
+};
+
+const PingFinderConfig: React.FC = () => {
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [newFrequency, setNewFrequency] = useState('');
+ const [autoCenter, setAutoCenter] = useState(true);
+
+ const context = useContext(GlobalAppContext);
+
+ const addFrequency = useCallback(() => {
+ if (!context) return;
+ const { pingFinderConfig, setPingFinderConfig } = context;
+
+ const freqMHz = parseFloat(newFrequency);
+ if (isNaN(freqMHz)) return;
+
+ const freqHz = freqMHz * 1_000_000;
+ const newFreqs = [...pingFinderConfig.targetFrequencies, freqHz];
+
+ setPingFinderConfig({
+ ...pingFinderConfig,
+ centerFrequency: autoCenter ? calculateCenterFrequency(newFreqs, pingFinderConfig.centerFrequency) : pingFinderConfig.centerFrequency,
+ targetFrequencies: newFreqs
+ });
+ setNewFrequency('');
+ }, [context, newFrequency, autoCenter]);
+
+ const removeFrequency = useCallback((freq: number) => {
+ if (!context) return;
+ const { pingFinderConfig, setPingFinderConfig } = context;
+
+ const newFreqs = pingFinderConfig.targetFrequencies.filter((f: number) => f !== freq);
+ setPingFinderConfig({
+ ...pingFinderConfig,
+ centerFrequency: autoCenter ? calculateCenterFrequency(newFreqs, pingFinderConfig.centerFrequency) : pingFinderConfig.centerFrequency,
+ targetFrequencies: newFreqs
+ });
+ }, [context, autoCenter]);
+
+ if (!context) return null;
+ const { pingFinderConfig, setPingFinderConfig, sendPingFinderConfig, cancelPingFinderConfig, gcsState } = context;
+
+ const isWaiting = gcsState === GCSState.PING_FINDER_CONFIG_WAITING;
+ const isTimeout = gcsState === GCSState.PING_FINDER_CONFIG_TIMEOUT;
+ const isInput = gcsState === GCSState.PING_FINDER_CONFIG_INPUT;
+
+ return (
+
+
+ {/* Frequency Input Section */}
+
+
+
+
+ setNewFrequency(e.target.value)}
+ placeholder="Enter frequency in MHz"
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm
+ focus:ring-2 focus:ring-blue-500 focus:border-blue-500
+ placeholder:text-gray-400 text-sm"
+ />
+
+
+
+
+ {/* Frequency List */}
+
+ {pingFinderConfig.targetFrequencies.map((freq) => (
+
+
+ {(freq / 1_000_000).toFixed(3)} MHz
+
+
+
+ ))}
+
+
+
+ {/* Auto Center Toggle */}
+
+
+ setAutoCenter(e.target.checked)}
+ className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ />
+
+
+
+
+
+ {/* Advanced Settings */}
+ {showAdvanced && (
+
+
+
+
+
+
+
+
+
+ setPingFinderConfig({...pingFinderConfig, pingMinLenMult: parseFloat(e.target.value)})}
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm
+ focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
+ />
+
+
+
+
setPingFinderConfig({...pingFinderConfig, enableTestData: e.target.checked})}
+ className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
+ />
+
+
+
+
+
+
+ )}
+
+ {/* Action Buttons */}
+
+ {(isWaiting || isTimeout) ? (
+
+
+
+
+
+ Configuring ping finder...
+
+
+ This may take a few seconds
+
+
+
+
+ {isTimeout && (
+ <>
+
+
+ Request is taking longer than expected. You may cancel and try again.
+
+ >
+ )}
+
+ ) : (
+
+ )}
+ {isInput && pingFinderConfig.targetFrequencies.length === 0 && (
+
+ Please add at least one target frequency
+
+ )}
+
+
+
+ );
+};
+
+export default PingFinderConfig;
\ No newline at end of file
diff --git a/frontend/src/components/device/cards/RadioConfig.tsx b/frontend/src/components/device/cards/RadioConfig.tsx
new file mode 100644
index 0000000..fdf036c
--- /dev/null
+++ b/frontend/src/components/device/cards/RadioConfig.tsx
@@ -0,0 +1,350 @@
+import React, { useState, useContext } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import { ArrowPathIcon, ComputerDesktopIcon, CpuChipIcon, WifiIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import Card from '../../common/Card';
+import { GCSState } from '../../../context/globalAppTypes';
+
+const RadioConfig: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('RadioConfig must be used within GlobalAppProvider');
+
+ const {
+ radioConfig,
+ setRadioConfig,
+ loadSerialPorts,
+ sendRadioConfig,
+ cancelRadioConfig,
+ gcsState
+ } = context;
+
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [isCustomBaudRate, setIsCustomBaudRate] = useState(false);
+
+ const handleInterfaceChange = (e: React.ChangeEvent) => {
+ const value = e.target.value as 'serial' | 'simulated';
+ setRadioConfig({
+ ...radioConfig,
+ interface_type: value
+ });
+ };
+
+ const handlePortChange = (e: React.ChangeEvent) => {
+ setRadioConfig({
+ ...radioConfig,
+ selectedPort: e.target.value
+ });
+ };
+
+ const handleBaudRateChange = (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value);
+ if (e.target.tagName === 'SELECT') {
+ if (e.target.value === 'custom') {
+ setIsCustomBaudRate(true);
+ return;
+ }
+ setIsCustomBaudRate(false);
+ }
+ if (!isNaN(value)) {
+ setRadioConfig({
+ ...radioConfig,
+ baudRate: value
+ });
+ }
+ };
+
+ const handleHostChange = (e: React.ChangeEvent) => {
+ setRadioConfig({
+ ...radioConfig,
+ host: e.target.value
+ });
+ };
+
+ const handleTcpPortChange = (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value);
+ if (!isNaN(value)) {
+ setRadioConfig({
+ ...radioConfig,
+ tcpPort: value
+ });
+ }
+ };
+
+ const handleAckTimeoutChange = (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value);
+ if (!isNaN(value)) {
+ setRadioConfig({
+ ...radioConfig,
+ ackTimeout: value
+ });
+ }
+ };
+
+ const handleMaxRetriesChange = (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value);
+ if (!isNaN(value)) {
+ setRadioConfig({
+ ...radioConfig,
+ maxRetries: value
+ });
+ }
+ };
+
+ const handleConnect = async () => {
+ await sendRadioConfig();
+ };
+
+ const handleCancel = () => {
+ cancelRadioConfig();
+ };
+
+ const isConnecting = gcsState === GCSState.RADIO_CONFIG_WAITING;
+ const showCancelSync = gcsState === GCSState.RADIO_CONFIG_TIMEOUT;
+
+ return (
+
+
+ {/* Interface Type Selection */}
+
+
+
+
+
+
+
+
+ {radioConfig.interface_type === 'serial' ? (
+ <>
+ {/* Serial Port Selection */}
+
+
+
+
+
+
+
+
+ {/* Baud Rate Selection */}
+
+
+ {!isCustomBaudRate ? (
+
+ ) : (
+
+
+
+
+ )}
+
+ >
+ ) : (
+ <>
+ {/* Simulated Connection Settings */}
+
+ >
+ )}
+
+ {/* Advanced Settings Toggle */}
+
+
+
+
+ {/* Advanced Settings */}
+ {showAdvanced && (
+
+
+
+
+
+ Time to wait for acknowledgment before retrying (100-5000ms)
+
+
+
+
+
+
+
+ Number of times to retry failed transmissions (1-10)
+
+
+
+ )}
+
+ {/* Connection Status and Actions */}
+
+ {!isConnecting && !showCancelSync ? (
+
+ ) : (
+
+
+
+
+
+ Establishing connection...
+
+
+ This may take a few seconds
+
+
+
+
+ {showCancelSync && (
+ <>
+
+
+ Connection is taking longer than expected. You may cancel and try again.
+
+ >
+ )}
+
+ )}
+
+
+
+ );
+};
+
+export default RadioConfig;
diff --git a/frontend/src/components/device/cards/SimulatorPopup.tsx b/frontend/src/components/device/cards/SimulatorPopup.tsx
new file mode 100644
index 0000000..7d1bb64
--- /dev/null
+++ b/frontend/src/components/device/cards/SimulatorPopup.tsx
@@ -0,0 +1,242 @@
+import React, { useState, useContext, useEffect } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import { PlayIcon, StopIcon, WifiIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import type { RadioConfig } from '../../../types/global';
+
+interface SimulatorPopupProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+const SimulatorPopup: React.FC = ({ isOpen, onClose }) => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('SimulatorPopup must be used within GlobalAppProvider');
+
+ const {
+ radioConfig,
+ setRadioConfig,
+ isSimulatorRunning,
+ initSimulator,
+ cleanupSimulator
+ } = context;
+
+ const [showAdvanced, setShowAdvanced] = useState(false);
+
+ // Close on escape key
+ useEffect(() => {
+ const handleEscape = (e: globalThis.KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handleEscape);
+ return () => window.removeEventListener('keydown', handleEscape);
+ }, [onClose]);
+
+ if (!isOpen) return null;
+
+ const handleHostChange = (e: React.ChangeEvent) => {
+ setRadioConfig({
+ ...radioConfig,
+ host: e.target.value
+ });
+ };
+
+ const handleTcpPortChange = (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value);
+ if (!isNaN(value)) {
+ setRadioConfig({
+ ...radioConfig,
+ tcpPort: value
+ });
+ }
+ };
+
+ const handleAckTimeoutChange = (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value);
+ if (!isNaN(value)) {
+ setRadioConfig({
+ ...radioConfig,
+ ackTimeout: value
+ });
+ }
+ };
+
+ const handleMaxRetriesChange = (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value);
+ if (!isNaN(value)) {
+ setRadioConfig({
+ ...radioConfig,
+ maxRetries: value
+ });
+ }
+ };
+
+ const handleStartSimulator = async () => {
+ const config: RadioConfig = {
+ interface_type: 'simulated',
+ port: '',
+ baudrate: radioConfig.baudRate,
+ host: radioConfig.host,
+ tcp_port: radioConfig.tcpPort,
+ ack_timeout: radioConfig.ackTimeout / 1000, // Convert to seconds
+ max_retries: radioConfig.maxRetries
+ };
+ await initSimulator(config);
+ };
+
+ const handleStopSimulator = async () => {
+ await cleanupSimulator();
+ };
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Popup */}
+
+
+
Simulator Control Panel
+
+
+
+
+ {/* Status Indicator */}
+
+
+
+ {isSimulatorRunning ? 'Simulator Running' : 'Simulator Stopped'}
+
+
+
+ {/* Connection Settings */}
+
+
+ {/* Advanced Settings Toggle */}
+
+
+
+
+ {/* Advanced Settings */}
+ {showAdvanced && (
+
+
+
+
+
+ Time to wait for acknowledgment before retrying (100-5000ms)
+
+
+
+
+
+
+
+ Number of times to retry failed transmissions (1-10)
+
+
+
+ )}
+
+ {/* Simulator Controls */}
+
+ {!isSimulatorRunning ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
+};
+
+export default SimulatorPopup;
\ No newline at end of file
diff --git a/frontend/src/components/device/cards/Start.tsx b/frontend/src/components/device/cards/Start.tsx
new file mode 100644
index 0000000..339d2c5
--- /dev/null
+++ b/frontend/src/components/device/cards/Start.tsx
@@ -0,0 +1,74 @@
+import React, { useContext } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import { GCSState } from '../../../context/globalAppTypes';
+import { ArrowPathIcon, SignalIcon } from '@heroicons/react/24/outline';
+import Card from '../../common/Card';
+
+const Start: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) return null;
+ const { start, cancelStart, gcsState } = context;
+
+ const isWaiting = gcsState === GCSState.START_WAITING;
+ const isTimeout = gcsState === GCSState.START_TIMEOUT;
+ const isInput = gcsState === GCSState.START_INPUT;
+
+ return (
+
+
+ {(isWaiting || isTimeout) ? (
+
+
+
+
+
+ Starting ping finder...
+
+
+ This may take a few seconds
+
+
+
+
+ {isTimeout && (
+ <>
+
+
+ Request is taking longer than expected. You may cancel and try again.
+
+ >
+ )}
+
+ ) : (
+
+ )}
+ {isInput && (
+
+ Begin scanning for radio pings on configured frequencies
+
+ )}
+
+
+ );
+};
+
+export default Start;
\ No newline at end of file
diff --git a/frontend/src/components/device/cards/Stop.tsx b/frontend/src/components/device/cards/Stop.tsx
new file mode 100644
index 0000000..4fd24f6
--- /dev/null
+++ b/frontend/src/components/device/cards/Stop.tsx
@@ -0,0 +1,74 @@
+import React, { useContext } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import { GCSState } from '../../../context/globalAppTypes';
+import { ArrowPathIcon, NoSymbolIcon } from '@heroicons/react/24/outline';
+import Card from '../../common/Card';
+
+const Stop: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) return null;
+ const { stop, cancelStop, gcsState } = context;
+
+ const isWaiting = gcsState === GCSState.STOP_WAITING;
+ const isTimeout = gcsState === GCSState.STOP_TIMEOUT;
+ const isInput = gcsState === GCSState.STOP_INPUT;
+
+ return (
+
+
+ {(isWaiting || isTimeout) ? (
+
+
+
+
+
+ Stopping ping finder...
+
+
+ This may take a few seconds
+
+
+
+
+ {isTimeout && (
+ <>
+
+
+ Request is taking longer than expected. You may cancel and try again.
+
+ >
+ )}
+
+ ) : (
+
+ )}
+ {isInput && (
+
+ Stop scanning for radio pings
+
+ )}
+
+
+ );
+};
+
+export default Stop;
\ No newline at end of file
diff --git a/frontend/src/components/manager/FrequencyManager.tsx b/frontend/src/components/manager/FrequencyManager.tsx
new file mode 100644
index 0000000..3ccd196
--- /dev/null
+++ b/frontend/src/components/manager/FrequencyManager.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import FrequencyLayersControl from './cards/FrequencyLayersControl';
+import Card from '../common/Card';
+
+const FrequencyManager: React.FC = () => {
+ return (
+
+
+
+ );
+};
+
+export default FrequencyManager;
diff --git a/frontend/src/components/manager/cards/FrequencyLayersControl.tsx b/frontend/src/components/manager/cards/FrequencyLayersControl.tsx
new file mode 100644
index 0000000..e6e86a0
--- /dev/null
+++ b/frontend/src/components/manager/cards/FrequencyLayersControl.tsx
@@ -0,0 +1,209 @@
+import React, { useContext } from 'react';
+import { GlobalAppContext } from '../../../context/globalAppContextDef';
+import { MapPinIcon, SignalIcon, TrashIcon } from '@heroicons/react/24/outline';
+import Card from '../../common/Card';
+
+const FrequencyLayersControl: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('FrequencyLayersControl must be in GlobalAppProvider');
+
+ const {
+ frequencyData,
+ frequencyVisibility,
+ setFrequencyVisibility,
+ deleteFrequencyLayer
+ } = context;
+
+ const toggleLayerVisibility = (frequency: number, type: 'pings' | 'location' | 'both') => {
+ const newVisibility = frequencyVisibility.map(layer => {
+ if (layer.frequency !== frequency) return layer;
+
+ if (type === 'both') {
+ const newValue = !(layer.visible_pings || layer.visible_location_estimate);
+ return {
+ ...layer,
+ visible_pings: newValue,
+ visible_location_estimate: newValue
+ };
+ }
+
+ if (type === 'pings') {
+ return {
+ ...layer,
+ visible_pings: !layer.visible_pings,
+ // Keep location estimate visibility unchanged
+ visible_location_estimate: layer.visible_location_estimate
+ };
+ }
+
+ // type === 'location'
+ return {
+ ...layer,
+ visible_pings: layer.visible_pings,
+ visible_location_estimate: !layer.visible_location_estimate
+ };
+ });
+ setFrequencyVisibility(newVisibility);
+ };
+
+ const toggleAllVisibility = (type: 'pings' | 'location' | 'both') => {
+ // Determine current state to toggle
+ const allVisible = frequencyVisibility.every(layer => {
+ if (type === 'pings') return layer.visible_pings;
+ if (type === 'location') return layer.visible_location_estimate;
+ return layer.visible_pings && layer.visible_location_estimate;
+ });
+
+ const newVisibility = frequencyVisibility.map(layer => {
+ if (type === 'both') {
+ return {
+ ...layer,
+ visible_pings: !allVisible,
+ visible_location_estimate: !allVisible
+ };
+ }
+ if (type === 'pings') {
+ return {
+ ...layer,
+ visible_pings: !allVisible,
+ visible_location_estimate: layer.visible_location_estimate
+ };
+ }
+ // type === 'location'
+ return {
+ ...layer,
+ visible_pings: layer.visible_pings,
+ visible_location_estimate: !allVisible
+ };
+ });
+ setFrequencyVisibility(newVisibility);
+ };
+
+ if (frequencyVisibility.length === 0) {
+ return (
+
+
+
+
+ No frequencies are being tracked yet.
+
+ Configure and start the ping finder to begin tracking.
+
+
+
+ );
+ }
+
+ const allPingsVisible = frequencyVisibility.every(layer => layer.visible_pings);
+ const allLocationsVisible = frequencyVisibility.every(layer => layer.visible_location_estimate);
+ const allVisible = allPingsVisible && allLocationsVisible;
+
+ return (
+
+
+ {/* Global Controls */}
+
+
+ All Frequencies
+
+
+
+
+ Pings
+
+
+
+ Location Estimates
+
+
+
+
+
+ {/* Frequency List */}
+
+ {frequencyVisibility.map((layer) => {
+ const data = frequencyData[layer.frequency];
+ const hasLocation = data?.locationEstimate !== undefined;
+ return (
+
+
+
+
+
+
+
+
+
+ {(layer.frequency / 1_000_000).toFixed(3)} MHz
+
+
+ {data?.pings.length} ping{data?.pings.length !== 1 ? 's' : ''} detected
+ {hasLocation && ' • Location available'}
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default FrequencyLayersControl;
diff --git a/frontend/src/components/map/DataLayers.tsx b/frontend/src/components/map/DataLayers.tsx
new file mode 100644
index 0000000..eb9e300
--- /dev/null
+++ b/frontend/src/components/map/DataLayers.tsx
@@ -0,0 +1,377 @@
+import React, { useEffect, useCallback, useState, useRef, useMemo, useContext } from 'react';
+import { useMap } from 'react-leaflet';
+import L from 'leaflet';
+import { formatDistanceToNow, isValid } from 'date-fns';
+import { logToPython } from '../../utils/logging';
+import type { GpsData, TimeoutRef, PingData, LocEstData } from '../../types/global';
+import { GlobalAppContext } from '../../context/globalAppContextDef';
+import { ArrowPathIcon } from '@heroicons/react/24/outline';
+
+// Helper functions for visualization
+const normalize = (value: number, min: number, max: number) => {
+ if (min === max) return 0.5;
+ return (value - min) / (max - min);
+};
+
+// Generate a distinct color based on frequency
+const getFrequencyColor = (frequency: number): string => {
+ // Use golden ratio to generate well-distributed hues
+ const goldenRatio = 0.618033988749895;
+ const hue = ((frequency * goldenRatio) % 1) * 360;
+ return `hsl(${hue}, 70%, 45%)`; // Saturation and lightness fixed for good visibility
+};
+
+// Get color for amplitude (used for stroke color of pings)
+const getAmplitudeColor = (normalizedValue: number) => {
+ const hue = (1 - normalizedValue) * 240;
+ return `hsl(${hue}, 100%, 50%)`;
+};
+
+// Create a ping icon using SignalIcon
+const createPingIcon = (color: string, strokeColor: string, size: number): L.DivIcon => {
+ const iconHtml = `
+
+ `;
+
+ return L.divIcon({
+ html: iconHtml,
+ className: '',
+ iconSize: [size, size],
+ iconAnchor: [size/2, size/2],
+ });
+};
+
+// Create a location estimate icon
+const createLocationEstimateIcon = (color: string): L.DivIcon => {
+ const iconHtml = `
+
+ `;
+
+ return L.divIcon({
+ html: iconHtml,
+ className: '',
+ iconSize: [32, 32],
+ iconAnchor: [16, 32],
+ });
+};
+
+interface LoadingIndicatorProps {
+ loading: boolean;
+}
+
+const LoadingIndicator: React.FC = ({ loading }) => {
+ if (!loading) return null;
+ return (
+
+
+
Loading map data...
+
+ );
+};
+
+interface FrequencyLayerProps {
+ frequency: number;
+ pings: PingData[];
+ locationEstimate: LocEstData | null;
+ visible_pings: boolean;
+ visible_location_estimate: boolean;
+}
+
+const FrequencyLayer: React.FC = ({
+ frequency,
+ pings,
+ locationEstimate,
+ visible_pings,
+ visible_location_estimate,
+}) => {
+ const map = useMap();
+ const markersRef = useRef([]);
+ const locationMarkerRef = useRef(null);
+ const cleanupMarkers = useCallback(() => {
+ markersRef.current.forEach(marker => marker.remove());
+ markersRef.current = [];
+ }, []);
+
+ // Get a distinct color for this frequency
+ const frequencyColor = useMemo(() => getFrequencyColor(frequency), [frequency]);
+
+ // Effect for ping markers
+ useEffect(() => {
+ if (!map || !visible_pings) {
+ cleanupMarkers();
+ return;
+ }
+
+ // Clean up old markers
+ cleanupMarkers();
+
+ if (pings.length > 0) {
+ const amplitudes = pings.map(ping => ping.amplitude);
+ const minAmplitude = Math.min(...amplitudes);
+ const maxAmplitude = Math.max(...amplitudes);
+
+ pings.forEach(ping => {
+ const normalizedAmplitude = normalize(ping.amplitude, minAmplitude, maxAmplitude);
+ const strokeColor = getAmplitudeColor(normalizedAmplitude);
+ const size = 12 + normalizedAmplitude * 12;
+
+ const icon = createPingIcon(frequencyColor, strokeColor, size);
+ const marker = L.marker([ping.lat, ping.long], { icon }).addTo(map);
+
+ marker.bindTooltip(`
+
+
Frequency: ${(frequency / 1_000_000).toFixed(3)} MHz
+
Amplitude: ${ping.amplitude.toFixed(2)} dB
+
Lat: ${ping.lat.toFixed(6)}°
+
Long: ${ping.long.toFixed(6)}°
+
Time: ${new Date(ping.timestamp / 1000).toLocaleTimeString()}
+
+ `, {
+ className: 'bg-white/95 border-0 shadow-lg rounded-lg',
+ offset: [0, -size/2]
+ });
+
+ markersRef.current.push(marker);
+ });
+ }
+
+ return cleanupMarkers;
+ }, [map, frequency, pings, visible_pings, cleanupMarkers, frequencyColor]);
+
+ // Separate effect for location estimate marker
+ useEffect(() => {
+ if (!map || !visible_location_estimate || !locationEstimate) {
+ if (locationMarkerRef.current) {
+ locationMarkerRef.current.remove();
+ locationMarkerRef.current = null;
+ }
+ return;
+ }
+
+ const icon = createLocationEstimateIcon(frequencyColor);
+ const marker = L.marker(
+ [locationEstimate.lat, locationEstimate.long],
+ { icon }
+ ).addTo(map);
+
+ marker.bindTooltip(`
+
+
Location Estimate
+
Frequency: ${(frequency / 1_000_000).toFixed(3)} MHz
+
Lat: ${locationEstimate.lat.toFixed(6)}°
+
Long: ${locationEstimate.long.toFixed(6)}°
+
Time: ${new Date(locationEstimate.timestamp / 1000).toLocaleTimeString()}
+
+ `, {
+ className: 'bg-white/95 border-0 shadow-lg rounded-lg',
+ offset: [0, -16]
+ });
+
+ locationMarkerRef.current = marker;
+
+ return () => {
+ if (locationMarkerRef.current) {
+ locationMarkerRef.current.remove();
+ locationMarkerRef.current = null;
+ }
+ };
+ }, [map, frequency, locationEstimate, visible_location_estimate, frequencyColor]);
+
+ return null;
+};
+
+const DroneMarker: React.FC<{
+ gpsData: GpsData | null;
+ isConnected: boolean;
+}> = ({ gpsData, isConnected }) => {
+ const map = useMap();
+ const droneMarkerRef = useRef(null);
+ const lastUpdateRef = useRef(0);
+
+ const formatLastUpdate = useCallback((timestamp: number | undefined) => {
+ try {
+ if (!timestamp) return 'Unknown';
+ const date = new Date(timestamp);
+ if (!isValid(date)) return 'Unknown';
+ return formatDistanceToNow(date, { addSuffix: true, includeSeconds: true });
+ } catch (err) {
+ logToPython(`Error formatting timestamp: ${err}`);
+ return 'Unknown';
+ }
+ }, []);
+
+ const getTooltipContent = useCallback((data: GpsData | null, lastUpdate: number) => {
+ if (!data) return 'Drone disconnected';
+ return `
+
+
Drone Status
+
Lat: ${data.lat.toFixed(6)}°
+
Long: ${data.long.toFixed(6)}°
+
Alt: ${data.altitude.toFixed(1)} m
+
Heading: ${data.heading.toFixed(1)}°
+
Last update: ${formatLastUpdate(lastUpdate)}
+
+ `;
+ }, [formatLastUpdate]);
+
+ const droneIconSvg = useMemo(() => `
+
+ `, []);
+
+ const drawDroneMarker = useCallback(
+ (data: GpsData | null, isConnected: boolean) => {
+ if (!map) return;
+
+ if (!isConnected || !data) {
+ if (droneMarkerRef.current) {
+ const icon = droneMarkerRef.current.getIcon();
+ if (icon instanceof L.DivIcon) {
+ icon.options.html = `${droneIconSvg}
`;
+ droneMarkerRef.current.setIcon(icon);
+ }
+ }
+ return;
+ }
+
+ const { lat, long, heading, timestamp } = data;
+ lastUpdateRef.current = timestamp / 1000;
+
+ if (!droneMarkerRef.current) {
+ const icon = L.divIcon({
+ className: '',
+ html: `${droneIconSvg}
`,
+ iconSize: [32, 32],
+ iconAnchor: [16, 16],
+ });
+ const marker = L.marker([lat, long], { icon }).addTo(map);
+ droneMarkerRef.current = marker;
+
+ marker.bindTooltip('', {
+ permanent: false,
+ direction: 'bottom',
+ offset: [0, 10],
+ className: 'bg-white/95 border-0 shadow-lg rounded px-2 py-1 text-sm'
+ });
+ marker.setTooltipContent(getTooltipContent(data, lastUpdateRef.current));
+ } else {
+ droneMarkerRef.current.setLatLng([lat, long]);
+ const icon = droneMarkerRef.current.getIcon();
+ if (icon instanceof L.DivIcon) {
+ icon.options.html = `${droneIconSvg}
`;
+ droneMarkerRef.current.setIcon(icon);
+ }
+ if (droneMarkerRef.current.getTooltip()) {
+ droneMarkerRef.current.setTooltipContent(getTooltipContent(data, lastUpdateRef.current));
+ }
+ }
+ },
+ [map, getTooltipContent, droneIconSvg]
+ );
+
+ useEffect(() => {
+ drawDroneMarker(gpsData, isConnected);
+
+ const interval = window.setInterval(() => {
+ if (droneMarkerRef.current?.getTooltip() && lastUpdateRef.current) {
+ droneMarkerRef.current.setTooltipContent(getTooltipContent(gpsData, lastUpdateRef.current));
+ }
+ }, 1000);
+
+ return () => {
+ window.clearInterval(interval);
+ if (droneMarkerRef.current) {
+ droneMarkerRef.current.remove();
+ droneMarkerRef.current = null;
+ }
+ };
+ }, [gpsData, isConnected, drawDroneMarker, getTooltipContent]);
+
+ return null;
+};
+
+const DataLayers: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('DataLayers must be in GlobalAppProvider');
+
+ const { gpsData, connectionStatus, frequencyData, frequencyVisibility } = context;
+ const [isLoading, setIsLoading] = useState(false);
+ const loadingTimeoutRef = useRef(null);
+ const map = useMap();
+
+ useEffect(() => {
+ if (!map) return;
+
+ const handleMoveStart = () => {
+ if (loadingTimeoutRef.current) clearTimeout(loadingTimeoutRef.current);
+ setIsLoading(true);
+ };
+
+ const handleMoveEnd = () => {
+ loadingTimeoutRef.current = setTimeout(() => setIsLoading(false), 1000);
+ };
+
+ map.on('movestart', handleMoveStart);
+ map.on('moveend', handleMoveEnd);
+ map.on('zoomstart', handleMoveStart);
+ map.on('zoomend', handleMoveEnd);
+
+ return () => {
+ map.off('movestart', handleMoveStart);
+ map.off('moveend', handleMoveEnd);
+ map.off('zoomstart', handleMoveStart);
+ map.off('zoomend', handleMoveEnd);
+ if (loadingTimeoutRef.current) clearTimeout(loadingTimeoutRef.current);
+ };
+ }, [map]);
+
+ return (
+ <>
+
+
+ {frequencyVisibility.map(({ frequency, visible_pings, visible_location_estimate }) => (
+
+ ))}
+ >
+ );
+};
+
+export default DataLayers;
diff --git a/frontend/src/components/map/MapContainer.tsx b/frontend/src/components/map/MapContainer.tsx
new file mode 100644
index 0000000..79ba28b
--- /dev/null
+++ b/frontend/src/components/map/MapContainer.tsx
@@ -0,0 +1,177 @@
+import React, { useContext, useEffect, useState, useRef } from 'react';
+import { MapContainer as LeafletMap, useMap, TileLayer } from 'react-leaflet';
+import 'leaflet/dist/leaflet.css';
+import { Map } from 'leaflet';
+import type { TileEvent } from 'leaflet';
+import NavigationControls from './NavigationControls';
+import DataLayers from './DataLayers';
+import { GlobalAppContext } from '../../context/globalAppContextDef';
+import { logToPython } from '../../utils/logging';
+
+const DEFAULT_CENTER: [number, number] = [32.8801, -117.2340];
+const DEFAULT_ZOOM = 13;
+const BATCH_DELAY = 50; // ms to wait before processing batch
+
+// A custom tile layer hooking into the backend
+const CustomTileLayer: React.FC<{
+ source: string;
+ isOffline: boolean;
+ attribution: string;
+ maxZoom: number;
+ minZoom: number;
+ onOfflineMiss: () => void;
+}> = ({ source, isOffline, attribution, maxZoom, minZoom, onOfflineMiss }) => {
+ const [isBackendReady, setIsBackendReady] = useState(false);
+ const pendingRequests = useRef>({});
+ const batchTimeoutRef = useRef(null);
+
+ useEffect(() => {
+ const initBackend = async () => {
+ await new Promise((resolve) => {
+ if (window.backendLoaded) resolve();
+ else window.addEventListener('backendLoaded', () => resolve(), { once: true });
+ });
+ setIsBackendReady(true);
+ };
+ initBackend();
+
+ // Cleanup function
+ return () => {
+ if (batchTimeoutRef.current !== null) {
+ window.clearTimeout(batchTimeoutRef.current);
+ }
+ // Cancel all pending requests
+ Object.values(pendingRequests.current).forEach(({ controller }) => controller.abort());
+ pendingRequests.current = {};
+ };
+ }, []);
+
+ const processTileBatch = async () => {
+ if (!isBackendReady) return;
+
+ const requests = { ...pendingRequests.current };
+ pendingRequests.current = {};
+ batchTimeoutRef.current = null;
+
+ await Promise.all(
+ Object.entries(requests).map(async ([key, { tile, controller }]) => {
+ if (controller.signal.aborted) return;
+
+ const [z, x, y] = key.split(',').map(Number);
+ try {
+ const tileData = await window.backend.get_tile(z, x, y, source, {
+ offline: isOffline,
+ });
+ if (controller.signal.aborted) return;
+
+ if (tileData) {
+ tile.src = `data:image/png;base64,${tileData}`;
+ } else if (isOffline) {
+ onOfflineMiss();
+ }
+ } catch (err) {
+ if (!controller.signal.aborted) {
+ const errorMsg = 'Error loading tile: ' + err;
+ console.error(errorMsg);
+ logToPython(errorMsg);
+ }
+ }
+ })
+ );
+ };
+
+ const eventHandlers = {
+ tileloadstart: (evt: TileEvent) => {
+ if (!isBackendReady) return;
+
+ const tile = evt.tile as HTMLImageElement;
+ const coords = evt.coords;
+ const key = `${coords.z},${coords.x},${coords.y}`;
+
+ // Cancel previous request for this tile if it exists
+ const existing = pendingRequests.current[key];
+ if (existing) {
+ existing.controller.abort();
+ }
+
+ // Create new request
+ const controller = new AbortController();
+ pendingRequests.current[key] = { tile, controller };
+
+ // Schedule batch processing
+ if (batchTimeoutRef.current !== null) {
+ window.clearTimeout(batchTimeoutRef.current);
+ }
+ batchTimeoutRef.current = window.setTimeout(processTileBatch, BATCH_DELAY);
+ },
+ };
+
+ return (
+
+ );
+};
+
+const FixMapSize: React.FC = () => {
+ const map = useMap();
+ useEffect(() => {
+ map.invalidateSize();
+ }, [map]);
+ return null;
+};
+
+const MapContainer: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('MapContainer must be in GlobalAppProvider');
+
+ const { currentMapSource, isMapOffline, mapRef } = context;
+ const [tileError, setTileError] = useState(null);
+
+ useEffect(() => {
+ setTileError(null);
+ }, [currentMapSource.id, isMapOffline]);
+
+ const onMapCreated = (map: Map) => {
+ mapRef.current = map;
+ };
+
+ return (
+
+
+
+ setTileError('Some tiles are not available offline. Please cache them in online mode first.')
+ }
+ />
+
+
+
+
+ {tileError && (
+
+ {tileError}
+
+ )}
+
+ );
+};
+
+export default MapContainer;
diff --git a/frontend/src/components/map/MapOverlayControls.tsx b/frontend/src/components/map/MapOverlayControls.tsx
new file mode 100644
index 0000000..49c7087
--- /dev/null
+++ b/frontend/src/components/map/MapOverlayControls.tsx
@@ -0,0 +1,208 @@
+import React, { useContext, useState } from 'react';
+import { GlobalAppContext } from '../../context/globalAppContextDef';
+import POIForm from '../poi/POIForm';
+import POIList from '../poi/POIList';
+import {
+ TrashIcon,
+ MapPinIcon,
+ Squares2X2Icon,
+ CloudArrowDownIcon,
+ ExclamationTriangleIcon,
+ XMarkIcon
+} from '@heroicons/react/24/outline';
+
+const ControlButton: React.FC<{
+ label: string;
+ onClick: () => void;
+ icon: React.ReactNode;
+ active?: boolean;
+ className?: string;
+ description?: string;
+}> = ({ label, onClick, icon, active, className = '', description }) => (
+
+);
+
+const MapOverlayControls: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('MapOverlayControls must be in GlobalAppProvider');
+
+ const {
+ tileInfo,
+ isMapOffline,
+ setIsMapOfflineUser,
+ mapSources,
+ currentMapSource,
+ setCurrentMapSource,
+ clearTileCache,
+ } = context;
+
+ const [showPOIPanel, setShowPOIPanel] = useState(false);
+ const [showMapSourcePanel, setShowMapSourcePanel] = useState(false);
+ const [showClearConfirmation, setShowClearConfirmation] = useState(false);
+
+ const handleClearTiles = async () => {
+ await clearTileCache();
+ setShowClearConfirmation(false);
+ };
+
+ return (
+
+
+ {/* Map Source */}
+
+
setShowMapSourcePanel(!showMapSourcePanel)}
+ icon={}
+ active={showMapSourcePanel}
+ />
+ {showMapSourcePanel && (
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Offline Toggle */}
+
setIsMapOfflineUser(!isMapOffline)}
+ icon={}
+ active={!isMapOffline}
+ />
+
+ {/* POI */}
+
+
setShowPOIPanel(!showPOIPanel)}
+ icon={}
+ active={showPOIPanel}
+ />
+ {showPOIPanel && (
+
+
+
Points of Interest
+
+
+
+
+
+ )}
+
+
+ {/* Clear Cache */}
+ setShowClearConfirmation(true)}
+ icon={}
+ className="text-red-500 hover:text-red-600 hover:bg-red-50"
+ />
+
+
+ {/* Clear Cache Confirmation Modal */}
+ {showClearConfirmation && (
+
+
+
+
+
+
Clear Map Cache?
+
+ This will remove all downloaded map tiles from your device.
+
+
+
+
+ {isMapOffline && (
+
+
+
+ Warning: You are in offline mode. Clearing the cache will prevent map tiles from loading until you disable offline mode.
+
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default MapOverlayControls;
diff --git a/frontend/src/components/map/NavigationControls.tsx b/frontend/src/components/map/NavigationControls.tsx
new file mode 100644
index 0000000..d13ca4c
--- /dev/null
+++ b/frontend/src/components/map/NavigationControls.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { useMap } from 'react-leaflet';
+import {
+ ChevronUpIcon,
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ PlusIcon,
+ MinusIcon,
+ ArrowsPointingInIcon,
+ HomeIcon
+} from '@heroicons/react/24/outline';
+
+interface ControlButtonProps {
+ onClick: () => void;
+ title: string;
+ icon: React.ReactNode;
+ className?: string;
+}
+
+const ControlButton: React.FC = ({ onClick, title, icon, className = '' }) => (
+
+);
+
+const NavigationControls: React.FC = () => {
+ const map = useMap();
+ const panAmount = 100; // pixels to pan
+
+ const handleResetView = () => {
+ map.setView([32.8801, -117.2340], 13);
+ };
+
+ const handleFitBounds = () => {
+ if (map.getBounds().isValid()) {
+ map.fitBounds(map.getBounds());
+ }
+ };
+
+ return (
+
+ {/* Zoom Controls */}
+
+ {/* Zoom In/Out */}
+
+
+
map.zoomIn()}
+ title="Zoom in"
+ icon={}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+ map.zoomOut()}
+ title="Zoom out"
+ icon={}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+
+
+ {/* View Controls */}
+
+
+
}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+
}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+
+
+
+ {/* Pan Controls */}
+
+
+
+ map.panBy([0, -panAmount])}
+ title="Pan up"
+ icon={}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+
+ map.panBy([-panAmount, 0])}
+ title="Pan left"
+ icon={}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+
+ map.panBy([panAmount, 0])}
+ title="Pan right"
+ icon={}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+
+ map.panBy([0, panAmount])}
+ title="Pan down"
+ icon={}
+ className="rounded-lg text-gray-700 hover:text-gray-900"
+ />
+
+
+
+
+ );
+};
+
+export default NavigationControls;
diff --git a/frontend/src/components/poi/POIForm.tsx b/frontend/src/components/poi/POIForm.tsx
new file mode 100644
index 0000000..24e6fd1
--- /dev/null
+++ b/frontend/src/components/poi/POIForm.tsx
@@ -0,0 +1,79 @@
+import React, { useContext, useState } from 'react';
+import { GlobalAppContext } from '../../context/globalAppContextDef';
+import { MapPinIcon, PlusIcon } from '@heroicons/react/24/outline';
+
+const POIForm: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('POIForm must be used inside GlobalAppProvider');
+
+ const { addPOI, loadPOIs, mapRef } = context;
+ const [poiName, setPoiName] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleAdd = async () => {
+ if (!poiName.trim()) return;
+
+ setIsSubmitting(true);
+ const center = mapRef.current?.getCenter();
+ const coords: [number, number] = center ? [center.lat, center.lng] : [0, 0];
+
+ try {
+ const success = await addPOI(poiName.trim(), coords);
+ if (success) {
+ loadPOIs();
+ setPoiName('');
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleAdd();
+ }
+ };
+
+ return (
+
+
+
+ Add New Location
+
+
+
+
setPoiName(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Enter location name..."
+ className="w-full pl-3 pr-24 py-2 text-sm border border-gray-300
+ rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500
+ focus:border-blue-500 placeholder-gray-400
+ transition-shadow duration-200"
+ disabled={isSubmitting}
+ />
+
+
+
+
+ The marker will be placed at the current map center
+
+
+ );
+};
+
+export default POIForm;
diff --git a/frontend/src/components/poi/POIList.tsx b/frontend/src/components/poi/POIList.tsx
new file mode 100644
index 0000000..f7b00e6
--- /dev/null
+++ b/frontend/src/components/poi/POIList.tsx
@@ -0,0 +1,177 @@
+import React, { useContext, useState } from 'react';
+import { GlobalAppContext } from '../../context/globalAppContextDef';
+import type { POI } from '../../types/global';
+import { logToPython } from '../../utils/logging';
+import {
+ MapPinIcon,
+ PencilIcon,
+ TrashIcon,
+ CheckIcon,
+ XMarkIcon,
+ ArrowTopRightOnSquareIcon
+} from '@heroicons/react/24/outline';
+
+interface POIItemProps {
+ poi: POI;
+ onRemove: (name: string) => void;
+ onGoto: (coords: [number, number]) => void;
+ onRename: (oldName: string, newName: string) => void;
+}
+
+const POIItem: React.FC = ({ poi, onRemove, onGoto, onRename }) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [newName, setNewName] = useState(poi.name);
+ const [isHovered, setIsHovered] = useState(false);
+
+ const handleRename = () => {
+ if (newName.trim() && newName !== poi.name) {
+ onRename(poi.name, newName.trim());
+ }
+ setIsEditing(false);
+ };
+
+ const handleCancel = () => {
+ setNewName(poi.name);
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleRename();
+ } else if (e.key === 'Escape') {
+ handleCancel();
+ }
+ };
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
+
+ {isEditing ? (
+
+ setNewName(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="flex-1 px-2 py-1 text-sm border border-gray-300
+ rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
+ autoFocus
+ />
+
+
+
+ ) : (
+
+
{poi.name}
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+const POIList: React.FC = () => {
+ const context = useContext(GlobalAppContext);
+ if (!context) throw new Error('POIList must be used inside GlobalAppProvider');
+
+ const { pois, loadPOIs, removePOI, mapRef } = context;
+
+ const handleRemovePOI = async (name: string) => {
+ const success = await removePOI(name);
+ if (success) {
+ loadPOIs();
+ }
+ };
+
+ const handleGotoPOI = (coords: [number, number]) => {
+ if (mapRef.current) {
+ mapRef.current.setView(coords, mapRef.current.getZoom());
+ }
+ };
+
+ const handleRenamePOI = async (oldName: string, newName: string) => {
+ if (window.backend) {
+ try {
+ const success = await window.backend.rename_poi(oldName, newName);
+ if (success) {
+ loadPOIs();
+ }
+ } catch (err) {
+ const errorMsg = `Error renaming POI from ${oldName} to ${newName}: ${err}`;
+ console.error(errorMsg);
+ logToPython(errorMsg);
+ }
+ }
+ };
+
+ return (
+
+
+ {pois.map((poi) => (
+
+ ))}
+ {pois.length === 0 && (
+
+
+ No locations added yet
+
+ )}
+
+
+ );
+};
+
+export default POIList;
diff --git a/frontend/src/context/GlobalAppContext.tsx b/frontend/src/context/GlobalAppContext.tsx
new file mode 100644
index 0000000..02ab035
--- /dev/null
+++ b/frontend/src/context/GlobalAppContext.tsx
@@ -0,0 +1,466 @@
+import React from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { GlobalAppState, PingFinderConfigState, RadioConfigState, FrequencyLayerVisibility } from './globalAppTypes';
+import { GpsData, PingFinderConfig, POI, RadioConfig } from '../types/global';
+import { MAP_SOURCES, MapSource } from '../utils/mapSources';
+import { useInternetStatus } from '../hooks/useInternetStatus';
+import { useConnectionQuality } from '../hooks/useConnectionQuality';
+import { useGCSStateMachine } from '../hooks/useGCSStateMachine';
+import { OFFLINE_MODE_KEY } from '../utils/mapSources';
+import { TileInfo } from '../types/global';
+import { GlobalAppContext } from './globalAppContextDef';
+import type { Map as LeafletMap } from 'leaflet';
+import { fetchBackend, FrequencyData } from '../utils/backend';
+import { logToPython } from '../utils/logging';
+import { GCSState } from './globalAppTypes';
+
+const GlobalAppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ // Internet & Map Status
+ const isOnline = useInternetStatus();
+ const [isMapOfflinePreference, setIsMapOfflinePreference] = useState(() => {
+ const saved = window.localStorage.getItem(OFFLINE_MODE_KEY);
+ return saved === 'true';
+ });
+
+ const isMapOffline = !isOnline || isMapOfflinePreference;
+
+ const setIsMapOfflineUser = (val: boolean) => {
+ setIsMapOfflinePreference(val);
+ window.localStorage.setItem(OFFLINE_MODE_KEY, val.toString());
+ };
+
+ // Map Data
+ const [currentMapSource, setCurrentMapSource] = useState(MAP_SOURCES[0]);
+ const [tileInfo, setTileInfo] = useState(null);
+ const [pois, setPois] = useState([]);
+ const [frequencyData, setFrequencyData] = useState({});
+ const [frequencyVisibility, setFrequencyVisibility] = useState([]);
+ const mapRef = useRef(null);
+
+ // GPS Data
+ const [gpsData, setGpsData] = useState(null);
+ const [gpsDataUpdated, setGpsDataUpdated] = useState(false);
+
+ // Radio Configuration
+ const [radioConfig, setRadioConfig] = useState({
+ interface_type: 'serial',
+ serialPorts: [],
+ selectedPort: '',
+ baudRate: 57600,
+ host: 'localhost',
+ tcpPort: 50000,
+ ackTimeout: 2000,
+ maxRetries: 5,
+ });
+
+ // Ping Finder Configuration
+ const [pingFinderConfig, setPingFinderConfig] = useState({
+ gain: 56.0,
+ samplingRate: 2500000,
+ centerFrequency: 173500000,
+ enableTestData: false,
+ pingWidthMs: 25,
+ pingMinSnr: 25,
+ pingMaxLenMult: 1.5,
+ pingMinLenMult: 0.5,
+ targetFrequencies: []
+ });
+
+ // Use our new hooks
+ const {
+ gcsState,
+ connectionStatus,
+ message,
+ messageVisible,
+ messageType,
+ setupStateHandlers,
+ setMessageVisible,
+ setGcsState
+ } = useGCSStateMachine(window.backend);
+
+ const { connectionQuality, pingTime, gpsFrequency } = useConnectionQuality(gpsData, connectionStatus === 1);
+
+ // Fatal error
+ const [fatalError, setFatalError] = useState(false);
+
+ // Simulator state
+ const [isSimulatorRunning, setIsSimulatorRunning] = useState(false);
+
+ // Effect to setup state handlers when backend is available
+ useEffect(() => {
+ (async () => {
+ const backend = await fetchBackend();
+ setupStateHandlers();
+
+ backend.gps_data_updated.connect((data: GpsData) => {
+ setGpsData(data);
+ setGpsDataUpdated(true);
+ });
+
+ backend.frequency_data_updated.connect((data: FrequencyData) => {
+ setFrequencyData(data);
+ setFrequencyVisibility(prev => {
+ const existingFreqs = new Set(prev.map(item => item.frequency));
+ const newFreqs = Object.entries(data)
+ .map(([freq]) => parseInt(freq))
+ .filter(freq => !existingFreqs.has(freq))
+ .map(freq => ({
+ frequency: freq,
+ visible_pings: true,
+ visible_location_estimate: true
+ }));
+ return [...prev, ...newFreqs];
+ });
+ });
+
+ backend.tile_info_updated.connect((info: TileInfo) => {
+ setTileInfo(info);
+ });
+
+ backend.pois_updated.connect((pois: POI[]) => {
+ setPois(pois);
+ });
+
+ backend.fatal_error.connect(() => {
+ setFatalError(true);
+ });
+
+ // Simulator signals
+ backend.simulator_started.connect(() => {
+ setIsSimulatorRunning(true);
+ });
+
+ backend.simulator_stopped.connect(() => {
+ setIsSimulatorRunning(false);
+ });
+
+ })();
+ }, [setupStateHandlers]);
+
+ // Callback functions
+ const deleteFrequencyLayer = useCallback(async (frequency: number) => {
+ if (!window.backend) return false;
+ try {
+ const success = await window.backend.clear_frequency_data(frequency);
+ if (!success) return false;
+ setFrequencyVisibility(prev => prev.filter(item => item.frequency !== frequency));
+ return true;
+ } catch (err) {
+ console.error('Error deleting frequency layer:', err);
+ return false;
+ }
+ }, []);
+
+ const deleteAllFrequencyLayers = useCallback(async () => {
+ if (!window.backend) return false;
+ try {
+ const success = await window.backend.clear_all_frequency_data();
+ if (!success) return false;
+ setFrequencyVisibility([]);
+ return true;
+ } catch (err) {
+ console.error('Error deleting all frequency layers:', err);
+ return false;
+ }
+ }, []);
+
+ const loadPOIs = useCallback(async () => {
+ if (!window.backend) return;
+ try {
+ const data = await window.backend.get_pois();
+ setPois(data);
+ } catch (err) {
+ console.error('Error loading POIs:', err);
+ }
+ }, []);
+
+ const addPOI = useCallback(async (name: string, coords: [number, number]) => {
+ if (!window.backend) return false;
+ try {
+ return await window.backend.add_poi(name, coords);
+ } catch (err) {
+ const errorMsg = 'Error adding POI: ' + err;
+ console.error(errorMsg);
+ logToPython(errorMsg);
+ return false;
+ }
+ }, []);
+
+ const removePOI = useCallback(async (name: string) => {
+ if (!window.backend) return false;
+ try {
+ return await window.backend.remove_poi(name);
+ } catch (err) {
+ const errorMsg = 'Error removing POI: ' + err;
+ console.error(errorMsg);
+ logToPython(errorMsg);
+ return false;
+ }
+ }, []);
+
+ const clearTileCache = useCallback(async () => {
+ if (!window.backend) return false;
+ try {
+ return await window.backend.clear_tile_cache();
+ } catch (err) {
+ const errorMsg = 'Error clearing tile cache: ' + err;
+ console.error(errorMsg);
+ logToPython(errorMsg);
+ return false;
+ }
+ }, []);
+
+ const loadSerialPorts = useCallback(async () => {
+ if (!window.backend) return;
+ try {
+ const ports = await window.backend.get_serial_ports();
+ setRadioConfig(prev => ({ ...prev, serialPorts: ports }));
+ } catch (err) {
+ const errorMsg = 'Error loading serial ports: ' + err;
+ console.error(errorMsg);
+ logToPython(errorMsg);
+ }
+ }, []);
+
+ const sendRadioConfig = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ const radioConfigSend = {
+ interface_type: radioConfig.interface_type,
+ port: radioConfig.selectedPort,
+ baudrate: radioConfig.baudRate,
+ host: radioConfig.host,
+ tcp_port: radioConfig.tcpPort,
+ ack_timeout: radioConfig.ackTimeout / 1000,
+ max_retries: radioConfig.maxRetries,
+ } as RadioConfig;
+
+ if (radioConfig.interface_type === 'serial') {
+ if (!radioConfig.selectedPort) return false;
+ } else {
+ if (!radioConfig.host || !radioConfig.tcpPort) return false;
+ }
+
+ try {
+ setGcsState(GCSState.RADIO_CONFIG_WAITING);
+ return await window.backend.initialize_comms(radioConfigSend);
+ } catch (e) {
+ console.error('Failed to sendRadioConfig', e);
+ setGcsState(GCSState.RADIO_CONFIG_INPUT);
+ return false;
+ }
+ }, [radioConfig, setMessageVisible, setGcsState]);
+
+ const cancelRadioConfig = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ try {
+ setGcsState(GCSState.RADIO_CONFIG_INPUT);
+ await window.backend.cancel_connection();
+ return true;
+ } catch (e) {
+ console.error('Failed to cancelRadioConfig', e);
+ return false;
+ }
+ }, [setMessageVisible, setGcsState]);
+
+ const sendPingFinderConfig = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+
+ const pingFinderConfigSend = {
+ gain: pingFinderConfig.gain,
+ sampling_rate: pingFinderConfig.samplingRate,
+ center_frequency: pingFinderConfig.centerFrequency,
+ enable_test_data: pingFinderConfig.enableTestData,
+ ping_width_ms: pingFinderConfig.pingWidthMs,
+ ping_min_snr: pingFinderConfig.pingMinSnr,
+ ping_max_len_mult: pingFinderConfig.pingMaxLenMult,
+ ping_min_len_mult: pingFinderConfig.pingMinLenMult,
+ target_frequencies: pingFinderConfig.targetFrequencies,
+ } as PingFinderConfig;
+
+ if (pingFinderConfig.targetFrequencies.length === 0) return false;
+
+ try {
+ setGcsState(GCSState.PING_FINDER_CONFIG_WAITING);
+ return await window.backend.send_config_request(pingFinderConfigSend);
+ } catch (e) {
+ console.error('Failed to sendPingFinderConfig', e);
+ setGcsState(GCSState.PING_FINDER_CONFIG_INPUT);
+ return false;
+ }
+ }, [pingFinderConfig, setMessageVisible, setGcsState]);
+
+ const cancelPingFinderConfig = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ try {
+ setGcsState(GCSState.PING_FINDER_CONFIG_INPUT);
+ return await window.backend.cancel_config_request();
+ } catch (e) {
+ console.error('Failed to cancelPingFinderConfig', e);
+ return false;
+ }
+ }, [setMessageVisible, setGcsState]);
+
+ const start = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ try {
+ setGcsState(GCSState.START_WAITING);
+ return await window.backend.send_start_request();
+ } catch (e) {
+ console.error('Failed to start', e);
+ setGcsState(GCSState.START_INPUT);
+ return false;
+ }
+ }, [setMessageVisible, setGcsState]);
+
+ const cancelStart = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ try {
+ setGcsState(GCSState.START_INPUT);
+ return await window.backend.cancel_start_request();
+ } catch (e) {
+ console.error('Failed to cancelStart', e);
+ return false;
+ }
+ }, [setMessageVisible, setGcsState]);
+
+ const stop = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ try {
+ setGcsState(GCSState.STOP_WAITING);
+ return await window.backend.send_stop_request();
+ } catch (e) {
+ console.error('Failed to stop', e);
+ setGcsState(GCSState.STOP_INPUT);
+ return false;
+ }
+ }, [setMessageVisible, setGcsState]);
+
+ const cancelStop = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ try {
+ setGcsState(GCSState.STOP_INPUT);
+ return await window.backend.cancel_stop_request();
+ } catch (e) {
+ console.error('Failed to cancelStop', e);
+ return false;
+ }
+ }, [setMessageVisible, setGcsState]);
+
+ const disconnect = useCallback(async () => {
+ setMessageVisible(false);
+ if (!window.backend) return false;
+ try {
+ await window.backend.disconnect();
+ return true;
+ } catch (e) {
+ console.error('Failed to disconnect', e);
+ return false;
+ }
+ }, [setMessageVisible]);
+
+ // Simulator functions
+ const initSimulator = useCallback(async (config: RadioConfig) => {
+ if (!window.backend) return false;
+ try {
+ return await window.backend.init_simulator(config);
+ } catch (e) {
+ console.error('Failed to initialize simulator', e);
+ return false;
+ }
+ }, []);
+
+ const cleanupSimulator = useCallback(async () => {
+ if (!window.backend) return false;
+ try {
+ return await window.backend.cleanup_simulator();
+ } catch (e) {
+ console.error('Failed to cleanup simulator', e);
+ return false;
+ }
+ }, []);
+
+ const value: GlobalAppState = {
+ // Simulator
+ initSimulator,
+ cleanupSimulator,
+ isSimulatorRunning,
+
+ // Connection Quality State
+ connectionQuality,
+ pingTime,
+ gpsFrequency,
+
+ // GCS State Machine
+ gcsState,
+ connectionStatus,
+ message,
+ messageVisible,
+ messageType,
+ setupStateHandlers,
+ setMessageVisible,
+ setGcsState,
+
+ // Map Data
+ isMapOffline,
+ setIsMapOfflineUser,
+ currentMapSource,
+ setCurrentMapSource,
+ mapSources: MAP_SOURCES,
+ tileInfo,
+ pois,
+ frequencyData,
+ deleteFrequencyLayer,
+ deleteAllFrequencyLayers,
+ frequencyVisibility,
+ setFrequencyVisibility,
+ mapRef,
+ loadPOIs,
+ addPOI,
+ removePOI,
+ clearTileCache,
+
+ // GPS Data
+ gpsData,
+ gpsDataUpdated,
+ setGpsDataUpdated,
+
+ // Radio Configuration
+ radioConfig,
+ setRadioConfig,
+ loadSerialPorts,
+ sendRadioConfig,
+ cancelRadioConfig,
+
+ // Ping Finder Configuration
+ pingFinderConfig,
+ setPingFinderConfig,
+ sendPingFinderConfig,
+ cancelPingFinderConfig,
+
+ // Control Operations
+ start,
+ cancelStart,
+ stop,
+ cancelStop,
+ disconnect,
+
+ fatalError,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default GlobalAppProvider;
+
diff --git a/frontend/src/context/globalAppContextDef.ts b/frontend/src/context/globalAppContextDef.ts
new file mode 100644
index 0000000..f7a5a28
--- /dev/null
+++ b/frontend/src/context/globalAppContextDef.ts
@@ -0,0 +1,4 @@
+import { createContext } from 'react';
+import type { GlobalAppState } from './globalAppTypes';
+
+export const GlobalAppContext = createContext(null);
\ No newline at end of file
diff --git a/frontend/src/context/globalAppTypes.ts b/frontend/src/context/globalAppTypes.ts
new file mode 100644
index 0000000..37a9f02
--- /dev/null
+++ b/frontend/src/context/globalAppTypes.ts
@@ -0,0 +1,119 @@
+import type { Map } from 'leaflet';
+import type { RefObject } from 'react';
+import type { GpsData, POI, TileInfo, RadioConfig } from '../types/global';
+import type { MapSource } from '../utils/mapSources';
+import { FrequencyData } from '../utils/backend';
+import type { ConnectionQualityState } from '../hooks/useConnectionQuality';
+import type { GCSStateMachineState } from '../hooks/useGCSStateMachine';
+
+export enum GCSState {
+ RADIO_CONFIG_INPUT = 'RADIO_CONFIG_INPUT',
+ RADIO_CONFIG_WAITING = 'RADIO_CONFIG_WAITING',
+ RADIO_CONFIG_TIMEOUT = 'RADIO_CONFIG_TIMEOUT',
+ PING_FINDER_CONFIG_INPUT = 'PING_FINDER_CONFIG_INPUT',
+ PING_FINDER_CONFIG_WAITING = 'PING_FINDER_CONFIG_WAITING',
+ PING_FINDER_CONFIG_TIMEOUT = 'PING_FINDER_CONFIG_TIMEOUT',
+ START_INPUT = 'START_INPUT',
+ START_WAITING = 'START_WAITING',
+ START_TIMEOUT = 'START_TIMEOUT',
+ STOP_INPUT = 'STOP_INPUT',
+ STOP_WAITING = 'STOP_WAITING',
+ STOP_TIMEOUT = 'STOP_TIMEOUT'
+}
+
+export interface RadioConfigState {
+ interface_type: 'serial' | 'simulated';
+ serialPorts: string[];
+ selectedPort: string;
+ baudRate: number;
+ host: string;
+ tcpPort: number;
+ ackTimeout: number;
+ maxRetries: number;
+}
+
+export interface PingFinderConfigState {
+ gain: number;
+ samplingRate: number;
+ centerFrequency: number;
+ enableTestData: boolean;
+ pingWidthMs: number;
+ pingMinSnr: number;
+ pingMaxLenMult: number;
+ pingMinLenMult: number;
+ targetFrequencies: number[];
+}
+
+export interface FrequencyLayerVisibility {
+ frequency: number;
+ visible_pings: boolean;
+ visible_location_estimate: boolean;
+}
+
+export interface GlobalAppState extends ConnectionQualityState, GCSStateMachineState {
+ // Simulator
+ initSimulator: (config: RadioConfig) => Promise;
+ cleanupSimulator: () => Promise;
+ isSimulatorRunning: boolean;
+
+ // Map Data
+ isMapOffline: boolean;
+ setIsMapOfflineUser: (val: boolean) => void;
+ currentMapSource: MapSource;
+ setCurrentMapSource: (src: MapSource) => void;
+ mapSources: MapSource[];
+ tileInfo: TileInfo | null;
+ pois: POI[];
+ frequencyData: FrequencyData;
+ deleteFrequencyLayer: (frequency: number) => void;
+ deleteAllFrequencyLayers: () => void;
+ frequencyVisibility: FrequencyLayerVisibility[];
+ setFrequencyVisibility: (visibility: FrequencyLayerVisibility[]) => void;
+ mapRef: RefObject