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: +

+
    +
  1. Retrieve your field device
  2. +
  3. Power cycle the field device
  4. +
  5. Close and restart the Ground Control System application
  6. +
+

+ 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 ? ( +
+
+ Disconnecting... +
+ ) : 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} +
+

+ {message} +

+
+
+
+
+ +
+ ); +}; + +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 && ( +
+
+
+ + { + const freqMHz = parseFloat(e.target.value); + if (isNaN(freqMHz)) return; + setPingFinderConfig({ + ...pingFinderConfig, + centerFrequency: Math.round(freqMHz * 1_000_000) + }); + }} + disabled={autoCenter} + 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 disabled:bg-gray-50 + disabled:text-gray-500 text-sm" + /> +
+
+ + setPingFinderConfig({...pingFinderConfig, gain: 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, samplingRate: parseInt(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, pingWidthMs: parseInt(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, pingMinSnr: 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, pingMaxLenMult: 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, 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; + loadPOIs: () => Promise; + addPOI: (name: string, coords: [number, number]) => Promise; + removePOI: (name: string) => Promise; + clearTileCache: () => Promise; + + // GPS data + gpsData: GpsData | null; + gpsDataUpdated: boolean; + setGpsDataUpdated: (updated: boolean) => void; + + // Radio configuration + radioConfig: RadioConfigState; + setRadioConfig: (config: RadioConfigState) => void; + loadSerialPorts: () => Promise; + sendRadioConfig: () => Promise; + cancelRadioConfig: () => void; + + // Ping finder configuration + pingFinderConfig: PingFinderConfigState; + setPingFinderConfig: (config: PingFinderConfigState) => void; + sendPingFinderConfig: () => Promise; + cancelPingFinderConfig: () => void; + + // Start + start: () => Promise; + cancelStart: () => void; + + // Stop + stop: () => Promise; + cancelStop: () => void; + + // Disconnect + disconnect: () => Promise; + + // GCS State Machine + gcsState: GCSState; + connectionStatus: 1 | 0; + message: string; + messageVisible: boolean; + messageType: 'error' | 'success'; + setupStateHandlers: () => void; + setMessageVisible: (visible: boolean) => void; + setGcsState: (state: GCSState) => void; + + // Fatal Error State + fatalError: boolean; +} diff --git a/frontend/src/hooks/useConnectionQuality.ts b/frontend/src/hooks/useConnectionQuality.ts new file mode 100644 index 0000000..215acfe --- /dev/null +++ b/frontend/src/hooks/useConnectionQuality.ts @@ -0,0 +1,84 @@ +import { useState, useEffect, useRef } from 'react'; +import type { GpsData } from '../types/global'; + +const WINDOW_SIZE = 10; + +export interface ConnectionQualityState { + connectionQuality: 5 | 4 | 3 | 2 | 1 | 0; + pingTime: number; + gpsFrequency: number; +} + +export function useConnectionQuality(gpsData: GpsData | null, isConnected: boolean): ConnectionQualityState { + const [connectionQuality, setConnectionQuality] = useState<5 | 4 | 3 | 2 | 1 | 0>(0); + const [pingTime, setPingTime] = useState(0); + const [gpsFrequency, setGpsFrequency] = useState(0); + const lastPacketRef = useRef<{ timestamp: number, receivedAt: number } | null>(null); + const packetIntervalsRef = useRef([]); + + const calculateConnectionQuality = (avgPingTime: number, avgFreq: number): 5 | 4 | 3 | 2 | 1 => { + const pingQuality = avgPingTime < 500 ? 5 : // More lenient ping thresholds + avgPingTime < 1000 ? 4 : + avgPingTime < 2000 ? 3 : + avgPingTime < 3000 ? 2 : 1; + + const freqQuality = avgFreq >= 0.8 ? 5 : // More lenient frequency thresholds + avgFreq >= 0.5 ? 4 : // data every ~2s + avgFreq >= 0.25 ? 3 : // data every 4s + avgFreq >= 0.1 ? 2 : // data every 10s + 1; // slower than every 10s + + // Average the qualities but round up for more leniency + return Math.ceil((pingQuality + freqQuality) / 2) as 5 | 4 | 3 | 2 | 1; + }; + + // Reset state when disconnected + useEffect(() => { + if (!isConnected) { + setConnectionQuality(0); + setPingTime(0); + setGpsFrequency(0); + packetIntervalsRef.current = []; + lastPacketRef.current = null; + } + }, [isConnected]); + + useEffect(() => { + if (!gpsData || !isConnected) return; + + const now = Date.now(); + const packetTimestamp = Math.floor(gpsData.timestamp / 1000); // Convert from microseconds to milliseconds + + // Calculate ping time for this packet + const currentPing = now - packetTimestamp; + + // Calculate packet interval and update frequency + if (lastPacketRef.current) { + const interval = packetTimestamp - lastPacketRef.current.timestamp; + + const intervals = [...packetIntervalsRef.current, interval]; + if (intervals.length > WINDOW_SIZE) { + intervals.shift(); + } + + // Calculate frequency using the latest intervals + const avgIntervalMs = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const freq = avgIntervalMs > 0 ? 1000 / avgIntervalMs : 0; + + packetIntervalsRef.current = intervals; + setGpsFrequency(freq); + + // Calculate quality using the latest values + const quality = calculateConnectionQuality(currentPing, freq); + setConnectionQuality(quality); + setPingTime(currentPing); + } else { + // First packet, just set ping time + setPingTime(currentPing); + } + + lastPacketRef.current = { timestamp: packetTimestamp, receivedAt: now }; + }, [gpsData, isConnected]); + + return { connectionQuality, pingTime, gpsFrequency }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useGCSStateMachine.ts b/frontend/src/hooks/useGCSStateMachine.ts new file mode 100644 index 0000000..2d96832 --- /dev/null +++ b/frontend/src/hooks/useGCSStateMachine.ts @@ -0,0 +1,153 @@ +import { useState, useCallback } from 'react'; +import { GCSState } from '../context/globalAppTypes'; +import type { DroneBackend } from '../utils/backend'; + +interface MessageState { + message: string; + messageVisible: boolean; + messageType: 'error' | 'success'; +} + +export interface GCSStateMachineState extends MessageState { + gcsState: GCSState; + connectionStatus: 1 | 0; + setupStateHandlers: () => void; + setMessageVisible: (visible: boolean) => void; + setGcsState: (state: GCSState) => void; +} + +export function useGCSStateMachine(backend: DroneBackend | null): GCSStateMachineState { + const [gcsState, setGcsState] = useState(GCSState.RADIO_CONFIG_INPUT); + const [connectionStatus, setConnectionStatus] = useState<1 | 0>(0); + const [message, setMessage] = useState(''); + const [messageVisible, setMessageVisible] = useState(false); + const [messageType, setMessageType] = useState<'error' | 'success'>('error'); + + const setupStateHandlers = useCallback(() => { + if (!backend) return; + + backend.sync_success.connect((msg: string) => { + if (gcsState === GCSState.RADIO_CONFIG_WAITING || gcsState === GCSState.RADIO_CONFIG_TIMEOUT) { + setGcsState(GCSState.PING_FINDER_CONFIG_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('success'); + setConnectionStatus(1); + } + }); + + backend.sync_failure.connect((msg: string) => { + if (gcsState === GCSState.RADIO_CONFIG_WAITING || gcsState === GCSState.RADIO_CONFIG_TIMEOUT) { + setGcsState(GCSState.RADIO_CONFIG_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('error'); + } + }); + + backend.sync_timeout.connect(() => { + if (gcsState === GCSState.RADIO_CONFIG_WAITING) { + setGcsState(GCSState.RADIO_CONFIG_TIMEOUT); + } + }); + + backend.config_success.connect((msg: string) => { + if (gcsState === GCSState.PING_FINDER_CONFIG_WAITING || gcsState === GCSState.PING_FINDER_CONFIG_TIMEOUT) { + setGcsState(GCSState.START_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('success'); + } + }); + + backend.config_failure.connect((msg: string) => { + if (gcsState === GCSState.PING_FINDER_CONFIG_WAITING || gcsState === GCSState.PING_FINDER_CONFIG_TIMEOUT) { + setGcsState(GCSState.PING_FINDER_CONFIG_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('error'); + } + }); + + backend.config_timeout.connect(() => { + if (gcsState === GCSState.PING_FINDER_CONFIG_WAITING) { + setGcsState(GCSState.PING_FINDER_CONFIG_TIMEOUT); + } + }); + + backend.start_success.connect((msg: string) => { + if (gcsState === GCSState.START_WAITING || gcsState === GCSState.START_TIMEOUT) { + setGcsState(GCSState.STOP_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('success'); + } + }); + + backend.start_failure.connect((msg: string) => { + if (gcsState === GCSState.START_WAITING || gcsState === GCSState.START_TIMEOUT) { + setGcsState(GCSState.START_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('error'); + } + }); + + backend.start_timeout.connect(() => { + if (gcsState === GCSState.START_WAITING) { + setGcsState(GCSState.START_TIMEOUT); + } + }); + + backend.stop_success.connect((msg: string) => { + if (gcsState === GCSState.STOP_WAITING || gcsState === GCSState.STOP_TIMEOUT) { + setGcsState(GCSState.PING_FINDER_CONFIG_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('success'); + } + }); + + backend.stop_failure.connect((msg: string) => { + if (gcsState === GCSState.STOP_WAITING || gcsState === GCSState.STOP_TIMEOUT) { + setGcsState(GCSState.STOP_INPUT); + setMessage(msg); + setMessageVisible(true); + setMessageType('error'); + } + }); + + backend.stop_timeout.connect(() => { + if (gcsState === GCSState.STOP_WAITING) { + setGcsState(GCSState.STOP_TIMEOUT); + } + }); + + backend.disconnect_success.connect((msg: string) => { + setGcsState(GCSState.RADIO_CONFIG_INPUT); + setConnectionStatus(0); + setMessage(msg); + setMessageVisible(true); + setMessageType('success'); + }); + + backend.disconnect_failure.connect((msg: string) => { + setGcsState(GCSState.RADIO_CONFIG_INPUT); + setConnectionStatus(0); + setMessage(msg); + setMessageVisible(true); + setMessageType('error'); + }); + }, [backend, gcsState]); + + return { + gcsState, + connectionStatus, + message, + messageVisible, + messageType, + setupStateHandlers, + setMessageVisible, + setGcsState + }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useInternetStatus.ts b/frontend/src/hooks/useInternetStatus.ts new file mode 100644 index 0000000..5fd2854 --- /dev/null +++ b/frontend/src/hooks/useInternetStatus.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +export const useInternetStatus = () => { + const [isOnline, setIsOnline] = useState(window.navigator.onLine); + + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return isOnline; +}; diff --git a/frontend/src/hooks/useSimulatorShortcut.ts b/frontend/src/hooks/useSimulatorShortcut.ts new file mode 100644 index 0000000..e8c8891 --- /dev/null +++ b/frontend/src/hooks/useSimulatorShortcut.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +export const useSimulatorShortcut = () => { + const [isSimulatorOpen, setIsSimulatorOpen] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: globalThis.KeyboardEvent) => { + // Check for Ctrl+Alt+S (Windows/Linux) or Cmd+Alt+S (Mac) + if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 's') { + e.preventDefault(); // Prevent default browser behavior + setIsSimulatorOpen(prev => !prev); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + return { + isSimulatorOpen, + setIsSimulatorOpen + }; +}; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..db53131 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,3 @@ +@import './styles/base.css'; +@import './styles/components.css'; +@import './styles/animations.css'; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..9dbd147 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/styles/animations.css b/frontend/src/styles/animations.css new file mode 100644 index 0000000..6c2d036 --- /dev/null +++ b/frontend/src/styles/animations.css @@ -0,0 +1,67 @@ +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.4; + } + + 50% { + transform: scale(1.5); + opacity: 0.1; + } + + 100% { + transform: scale(1); + opacity: 0.4; + } +} + +@keyframes glow { + 0% { + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.9)); + } + + 50% { + filter: drop-shadow(0 0 8px rgba(255, 255, 255, 1)); + } + + 100% { + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.9)); + } +} + +.drone-marker { + filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.9)); + animation: glow 2s ease-in-out infinite; +} + +.drone-marker-active { + @apply text-blue-500; + font-size: 32px; +} + +.drone-marker-disconnected { + @apply text-gray-400; + font-size: 32px; +} + +.drone-status-pulse { + @apply absolute inset-0 rounded-full; + transform-origin: center; +} + +.drone-marker-active .drone-status-pulse { + @apply bg-blue-500/20 border-4 border-blue-500/40; + animation: pulse 2s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +.drone-marker-disconnected .drone-status-pulse { + @apply bg-gray-400/20 border-4 border-gray-400/40; +} + +.drone-icon-container { + transform-origin: center; +} + +.drone-icon { + @apply fill-current stroke-white stroke-1; +} \ No newline at end of file diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css new file mode 100644 index 0000000..6f31dc6 --- /dev/null +++ b/frontend/src/styles/base.css @@ -0,0 +1,21 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} \ No newline at end of file diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css new file mode 100644 index 0000000..8e9a596 --- /dev/null +++ b/frontend/src/styles/components.css @@ -0,0 +1,41 @@ +@layer components { + .btn { + @apply px-4 py-2 rounded-lg font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2; + } + + .btn-primary { + @apply btn bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-500; + } + + .btn-danger { + @apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; + } + + .btn-outline { + @apply btn border-2 hover:bg-gray-50; + } + + .input { + @apply w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder:text-gray-400; + } + + .card { + @apply bg-white rounded-xl border border-gray-100 shadow-sm p-4; + } + + .button-primary { + @apply px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2; + } + + .button-secondary { + @apply px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2; + } + + .button-danger { + @apply px-4 py-2 text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2; + } + + .input-field { + @apply w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors; + } +} \ No newline at end of file diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts new file mode 100644 index 0000000..21c0d10 --- /dev/null +++ b/frontend/src/types/global.d.ts @@ -0,0 +1,74 @@ +import type { DroneBackend } from '../utils/backend'; + +declare global { + interface Window { + backend: DroneBackend; + backendLoaded: boolean; + } +} + +export type TimeoutRef = ReturnType; + +export interface GpsData { + lat: number; + long: number; + altitude: number; + heading: number; + timestamp: number; + packet_id: number; +} + +export interface PingData { + frequency: number; + amplitude: number; + lat: number; + long: number; + timestamp: number; + packet_id: number; +} + +export interface LocEstData { + frequency: number; + lat: number; + long: number; + timestamp: number; + packet_id: number; +} + +export interface POI { + name: string; + coords: [number, number]; +} + +export interface TileInfo { + total_tiles: number; + total_size_mb: number; +} + +export interface RadioConfig { + interface_type: 'serial' | 'simulated'; + port: string; + baudrate: number; + host: string; + tcp_port: number; + ack_timeout: number; + max_retries: number; +} + +export interface PingFinderConfig { + gain: number; + sampling_rate: number; + center_frequency: number; + enable_test_data: boolean; + ping_width_ms: number; + ping_min_snr: number; + ping_max_len_mult: number; + ping_min_len_mult: number; + target_frequencies: number[]; +} + +export interface TileOptions { + offline: boolean; +} + +export { }; diff --git a/frontend/src/utils/backend.ts b/frontend/src/utils/backend.ts new file mode 100644 index 0000000..8665c78 --- /dev/null +++ b/frontend/src/utils/backend.ts @@ -0,0 +1,110 @@ +import type { + GpsData, + PingData, + LocEstData, + POI, + TileInfo, + RadioConfig, + PingFinderConfig, + TileOptions +} from '../types/global'; + +export interface Signal { + connect: (callback: (data: T) => void) => void; + disconnect: (callback?: (data: T) => void) => void; +} + +export interface FrequencyData { + [frequency: number]: { + pings: PingData[]; + locationEstimate: LocEstData | null; + } +} + +export interface DroneBackend { + // Connection + get_serial_ports(): Promise; + initialize_comms(config: RadioConfig): Promise; + cancel_connection(): void; + disconnect(): void; + + // Simulator + init_simulator(config: RadioConfig): Promise; + cleanup_simulator(): Promise; + simulator_started: Signal; + simulator_stopped: Signal; + + // Data signals + gps_data_updated: Signal; + frequency_data_updated: Signal; + + // Fatal error signal + fatal_error: Signal; + + // Operation signals + sync_success: Signal; + sync_failure: Signal; + sync_timeout: Signal; + + config_success: Signal; + config_failure: Signal; + config_timeout: Signal; + + start_success: Signal; + start_failure: Signal; + start_timeout: Signal; + + stop_success: Signal; + stop_failure: Signal; + stop_timeout: Signal; + + disconnect_success: Signal; + disconnect_failure: Signal; + + // Tiles + get_tile(z: number, x: number, y: number, source: string, options: TileOptions): Promise; + get_tile_info(): Promise; + clear_tile_cache(): Promise; + tile_info_updated: Signal; + + // POIs + get_pois(): Promise; + add_poi(name: string, coords: [number, number]): Promise; + remove_poi(name: string): Promise; + rename_poi(oldName: string, newName: string): Promise; + pois_updated: Signal; + + // Config and Control + send_config_request(config: PingFinderConfig): Promise; + cancel_config_request(): Promise; + send_start_request(): Promise; + cancel_start_request(): Promise; + send_stop_request(): Promise; + cancel_stop_request(): Promise; + + // Data Management + clear_frequency_data(freq: number): Promise; + clear_all_frequency_data(): Promise; + + // Logging + log_message(message: string): void; +} + +declare global { + interface Window { + backend: DroneBackend; + backendLoaded: boolean; + } +} + +export async function fetchBackend(): Promise { + return new Promise((resolve) => { + if (window.backend) { + resolve(window.backend); + } else { + window.addEventListener('backendLoaded', () => { + resolve(window.backend); + }); + } + }); +} diff --git a/frontend/src/utils/logging.ts b/frontend/src/utils/logging.ts new file mode 100644 index 0000000..5a5ef24 --- /dev/null +++ b/frontend/src/utils/logging.ts @@ -0,0 +1,5 @@ +export function logToPython(message: string): void { + if (window.backend?.log_message) { + window.backend.log_message(message); + } +} diff --git a/frontend/src/utils/mapSources.ts b/frontend/src/utils/mapSources.ts new file mode 100644 index 0000000..5bbe3d9 --- /dev/null +++ b/frontend/src/utils/mapSources.ts @@ -0,0 +1,28 @@ +export interface MapSource { + id: string; + name: string; + attribution: string; + minZoom: number; + maxZoom: number; +} + +export const MAP_SOURCES: MapSource[] = [ + { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19, + }, + { + id: 'satellite', + name: 'Satellite', + attribution: + '© Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, ' + + 'Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community', + minZoom: 0, + maxZoom: 19, + }, +]; + +export const OFFLINE_MODE_KEY = 'mapOfflineMode'; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..f4c4f13 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,21 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx,css}', + ], + theme: { + extend: { + animation: { + 'fade-in': 'fadeIn 0.2s ease-in-out', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0', transform: 'translateY(-10px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + }, + }, + }, + plugins: [], +}; \ No newline at end of file diff --git a/frontend/tests/components/MainLayout.test.tsx b/frontend/tests/components/MainLayout.test.tsx new file mode 100644 index 0000000..318ea22 --- /dev/null +++ b/frontend/tests/components/MainLayout.test.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MainLayout from '../../src/MainLayout' +import { GlobalAppContext } from '../../src/context/globalAppContextDef' +import { describe, expect, it } from 'vitest' +import type { GlobalAppState } from '../../src/context/globalAppTypes' +import { GCSState } from '../../src/context/globalAppTypes' + +describe('MainLayout component', () => { + const defaultContext: Partial = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + frequencyVisibility: [], + frequencyData: {}, + gpsData: null, + connectionStatus: 0, + mapRef: { current: null } + } + + const renderWithContext = (contextOverrides = {}) => { + const mergedContext = { ...defaultContext, ...contextOverrides } as GlobalAppState + return render( + + + + ) + } + + it('switches to "Frequencies" tab when clicked', async () => { + renderWithContext() + const tab = screen.getByRole('button', { name: 'Frequencies' }) + await userEvent.click(tab) + expect(tab.className).toContain('bg-white text-blue-600') + }) +}) diff --git a/frontend/tests/components/common/Card.test.tsx b/frontend/tests/components/common/Card.test.tsx new file mode 100644 index 0000000..c5ded75 --- /dev/null +++ b/frontend/tests/components/common/Card.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import Card from '../../../src/components/common/Card' + +describe('Card component', () => { + it('renders children content', () => { + render( + +

Hello from Card!

+
+ ) + expect(screen.getByTestId('child-content')).toHaveTextContent('Hello from Card!') + }) + + it('renders title if provided', () => { + render(Some Content) + expect(screen.getByText('Test Title')).toBeInTheDocument() + }) + + it('applies extra className if provided', () => { + render(Child) + const cardElement = screen.getByText('Child').closest('div') + expect(cardElement).toHaveClass('my-card-class') + }) +}) diff --git a/frontend/tests/components/common/Modal.test.tsx b/frontend/tests/components/common/Modal.test.tsx new file mode 100644 index 0000000..c236cf0 --- /dev/null +++ b/frontend/tests/components/common/Modal.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { vi } from 'vitest' +import userEvent from '@testing-library/user-event' +import Modal from '../../../src/components/common/Modal' + +describe('Modal component', () => { + it('does not render when show=false', () => { + render( { }}>Hidden Content) + expect(screen.queryByText('Hidden Content')).not.toBeInTheDocument() + }) + + it('renders when show=true', () => { + render( { }}>Visible Content) + expect(screen.getByText('Visible Content')).toBeInTheDocument() + }) + + it('calls onClose when close button is clicked', async () => { + const mockClose = vi.fn() + render( + + Modal Content + + ) + const closeBtn = screen.getByRole('button') + await userEvent.click(closeBtn) + expect(mockClose).toHaveBeenCalled() + }) + + it('renders a title if provided', () => { + render( { }} title="My Modal Title">Content) + expect(screen.getByText('My Modal Title')).toBeInTheDocument() + }) +}) diff --git a/frontend/tests/components/device/DeviceControls.test.tsx b/frontend/tests/components/device/DeviceControls.test.tsx new file mode 100644 index 0000000..68dbf22 --- /dev/null +++ b/frontend/tests/components/device/DeviceControls.test.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { render } from '@testing-library/react' +import DeviceControls from '../../../src/components/device/DeviceControls' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GCSState, GlobalAppState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('DeviceControls component', () => { + const mockSetMessageVisible = vi.fn() + const mockSendRadioConfig = vi.fn() + const mockCancelRadioConfig = vi.fn() + const mockSendPingFinderConfig = vi.fn() + const mockCancelPingFinderConfig = vi.fn() + const mockStart = vi.fn() + const mockCancelStart = vi.fn() + const mockStop = vi.fn() + const mockCancelStop = vi.fn() + const mockDisconnect = vi.fn() + + const defaultContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + setMessageVisible: mockSetMessageVisible, + sendRadioConfig: mockSendRadioConfig, + cancelRadioConfig: mockCancelRadioConfig, + sendPingFinderConfig: mockSendPingFinderConfig, + cancelPingFinderConfig: mockCancelPingFinderConfig, + start: mockStart, + cancelStart: mockCancelStart, + stop: mockStop, + cancelStop: mockCancelStop, + disconnect: mockDisconnect, + // Map related + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + // GPS data + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + // Radio config + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + // Ping finder config + setPingFinderConfig: vi.fn(), + // Connection quality + connectionQuality: 0, + pingTime: 0, + gpsFrequency: 0, + // Connection status + connectionStatus: 0, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setGcsState: vi.fn(), + // Simulator + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + // Fatal error + fatalError: false + } + + it('renders DroneStatus always', () => { + const { getByText } = render( + + + + ) + expect(getByText('Drone Status')).toBeInTheDocument() + }) +}) diff --git a/frontend/tests/components/device/Disconnect.test.tsx b/frontend/tests/components/device/Disconnect.test.tsx new file mode 100644 index 0000000..1632e77 --- /dev/null +++ b/frontend/tests/components/device/Disconnect.test.tsx @@ -0,0 +1,127 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Disconnect from '../../../src/components/device/cards/Disconnect' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('Disconnect component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 4, + pingTime: 100, + gpsFrequency: 1, + connectionStatus: 1, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + it('renders disconnect button when connected', () => { + render( + + + + ) + + const disconnectButton = screen.getByRole('button', { name: /disconnect/i }) + expect(disconnectButton).toBeInTheDocument() + }) + + it('calls disconnect when confirmed', async () => { + const disconnectMock = vi.fn() + const mockContext = { + ...baseContext, + disconnect: disconnectMock + } + + render( + + + + ) + + // Click initial disconnect button to show confirmation + const disconnectButton = screen.getByRole('button', { name: /disconnect device/i }) + await userEvent.click(disconnectButton) + + // Click Yes on confirmation dialog + const confirmButton = screen.getByRole('button', { name: /yes, disconnect/i }) + await userEvent.click(confirmButton) + + expect(disconnectMock).toHaveBeenCalled() + }) +}) diff --git a/frontend/tests/components/device/DroneStatus.test.tsx b/frontend/tests/components/device/DroneStatus.test.tsx new file mode 100644 index 0000000..709b491 --- /dev/null +++ b/frontend/tests/components/device/DroneStatus.test.tsx @@ -0,0 +1,183 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DroneStatus from '../../../src/components/device/cards/DroneStatus' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { Map } from 'leaflet' +import { describe, expect, it, vi } from 'vitest' + +// Mock the useConnectionQuality hook +vi.mock('../../../src/hooks/useConnectionQuality', () => ({ + useConnectionQuality: () => ({ + connectionQuality: 4, + pingTime: 100, + gpsFrequency: 1 + }) +})) + +describe('DroneStatus component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null as unknown as Map }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: { + lat: 32.88, + long: -117.234, + altitude: 100, + heading: 90, + timestamp: Date.now(), + packet_id: 1 + }, + gpsDataUpdated: true, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 4, + pingTime: 100, + gpsFrequency: 1, + connectionStatus: 1, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + it('renders connection and GPS stats when connected', () => { + render( + + + + ) + + const container = screen.getByRole('heading', { name: /drone status/i }).closest('.card') + expect(container).toBeInTheDocument() + + // Check for ping time and GPS frequency + expect(container).toHaveTextContent('100ms') // From mock useConnectionQuality + expect(container).toHaveTextContent('1.0Hz') // From mock useConnectionQuality + + // Verify locate button is enabled + const locateButton = screen.getByRole('button', { name: /locate/i }) + expect(locateButton).toBeEnabled() + }) + + it('shows no data indicators when GPS data is unavailable', () => { + const noGpsContext = { + ...baseContext, + gpsData: null + } + render( + + + + ) + + const container = screen.getByRole('heading', { name: /drone status/i }).closest('.card') + expect(container).toHaveTextContent(/--/i) // Check for the no data indicator + + // Verify the locate button is disabled + const locateButton = screen.getByRole('button', { name: /locate/i }) + expect(locateButton).toBeDisabled() + }) + + it('displays connection quality indicator', () => { + render( + + + + ) + + // Check for presence of connection quality without exact formatting + const container = screen.getByRole('heading', { name: /drone status/i }).closest('.card') + expect(container).toHaveTextContent(/excellent range/i) // For connection quality 4 and connected status + }) + + it('handles locate button click', async () => { + const mockMapRef = { + current: { + setView: vi.fn(), + getZoom: () => 13 + } as unknown as Map + } + + const mockContext = { + ...baseContext, + mapRef: mockMapRef + } + + render( + + + + ) + + const locateButton = screen.getByRole('button', { name: /locate/i }) + await userEvent.click(locateButton) + expect(mockMapRef.current.setView).toHaveBeenCalledWith([32.88, -117.234], 13) + }) +}) diff --git a/frontend/tests/components/device/Message.test.tsx b/frontend/tests/components/device/Message.test.tsx new file mode 100644 index 0000000..5fe8be2 --- /dev/null +++ b/frontend/tests/components/device/Message.test.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Message from '../../../src/components/device/cards/Message' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('Message component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 0, + pingTime: 0, + gpsFrequency: 0, + connectionStatus: 0, + message: 'Test message', + messageVisible: true, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + it('renders message when visible', () => { + render( + + + + ) + // Check for presence of message container and content + const messageElement = screen.getByRole('alert') + expect(messageElement).toBeInTheDocument() + expect(messageElement).toHaveTextContent('Test message') + }) + + it('does not render when message is not visible', () => { + const hiddenContext = { + ...baseContext, + messageVisible: false + } + render( + + + + ) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('calls setMessageVisible when close button is clicked', async () => { + const setMessageVisibleMock = vi.fn() + const mockContext = { + ...baseContext, + setMessageVisible: setMessageVisibleMock + } + render( + + + + ) + + // Find close button by role and click it + const closeButton = screen.getByRole('button') + await userEvent.click(closeButton) + expect(setMessageVisibleMock).toHaveBeenCalledWith(false) + }) +}) diff --git a/frontend/tests/components/device/PingFinderConfig.test.tsx b/frontend/tests/components/device/PingFinderConfig.test.tsx new file mode 100644 index 0000000..ca50218 --- /dev/null +++ b/frontend/tests/components/device/PingFinderConfig.test.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import PingFinderConfig from '../../../src/components/device/cards/PingFinderConfig' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('PingFinderConfig component', () => { + const mockSetPingFinderConfig = vi.fn() + const mockSendPingFinderConfig = vi.fn() + + const baseContext: GlobalAppState = { + gcsState: GCSState.PING_FINDER_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: mockSetPingFinderConfig, + sendPingFinderConfig: mockSendPingFinderConfig, + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 0, + pingTime: 0, + gpsFrequency: 0, + connectionStatus: 0, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + const renderWithContext = (contextOverrides: Partial = {}) => { + const mergedContext = { ...baseContext, ...contextOverrides } + return render( + + + + ) + } + + it('renders the title', () => { + renderWithContext() + expect(screen.getByText('Ping Finder Configuration')).toBeInTheDocument() + }) +}) diff --git a/frontend/tests/components/device/RadioConfig.test.tsx b/frontend/tests/components/device/RadioConfig.test.tsx new file mode 100644 index 0000000..f057302 --- /dev/null +++ b/frontend/tests/components/device/RadioConfig.test.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import RadioConfig from '../../../src/components/device/cards/RadioConfig' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('RadioConfig component', () => { + const mockSetRadioConfig = vi.fn() + const mockSendRadioConfig = vi.fn() + + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: ['COM1', 'COM2'], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: mockSetRadioConfig, + loadSerialPorts: vi.fn(), + sendRadioConfig: mockSendRadioConfig, + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 0, + pingTime: 0, + gpsFrequency: 0, + connectionStatus: 0, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + const renderWithContext = (contextOverrides: Partial = {}) => { + const mergedContext = { ...baseContext, ...contextOverrides } + return render( + + + + ) + } + + it('renders the title', () => { + renderWithContext() + expect(screen.getByText('Radio Configuration')).toBeInTheDocument() + }) + + it('updates port when selected', async () => { + renderWithContext() + const select = screen.getAllByRole('combobox')[0] + await userEvent.selectOptions(select, 'COM1') + expect(mockSetRadioConfig).toHaveBeenCalledWith(expect.objectContaining({ + selectedPort: 'COM1' + })) + }) +}) diff --git a/frontend/tests/components/device/SimulatorPopup.test.tsx b/frontend/tests/components/device/SimulatorPopup.test.tsx new file mode 100644 index 0000000..3ce824f --- /dev/null +++ b/frontend/tests/components/device/SimulatorPopup.test.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SimulatorPopup from '../../../src/components/device/cards/SimulatorPopup' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('SimulatorPopup component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 0, + pingTime: 0, + gpsFrequency: 0, + connectionStatus: 0, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + it('does not render if isOpen=false', () => { + render( + + {}} /> + + ) + expect(screen.queryByText('Simulator Control Panel')).not.toBeInTheDocument() + }) + + it('renders if isOpen=true', () => { + render( + + {}} /> + + ) + expect(screen.getByText('Simulator Control Panel')).toBeInTheDocument() + }) + + it('calls onClose when backdrop is clicked', async () => { + const onCloseMock = vi.fn() + render( + + + + ) + const backdrop = screen.getByRole('presentation', { hidden: true }) + await userEvent.click(backdrop) + expect(onCloseMock).toHaveBeenCalled() + }) +}) diff --git a/frontend/tests/components/device/Start.test.tsx b/frontend/tests/components/device/Start.test.tsx new file mode 100644 index 0000000..295587c --- /dev/null +++ b/frontend/tests/components/device/Start.test.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Start from '../../../src/components/device/cards/Start' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('Start component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.START_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: ['COM1', 'COM2'], + selectedPort: 'COM1', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 4, + pingTime: 100, + gpsFrequency: 1, + connectionStatus: 1, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + const renderWithContext = (contextOverrides: Partial = {}) => { + const mergedContext = { ...baseContext, ...contextOverrides } + return render( + + + + ) + } + + it('renders start button in input state', () => { + render( + + + + ) + + const startButton = screen.getByRole('button', { name: /start/i }) + expect(startButton).toBeInTheDocument() + expect(startButton).toBeEnabled() + }) + + it('shows waiting message in waiting state', () => { + renderWithContext({ + gcsState: GCSState.START_WAITING + }) + + expect(screen.getByText('Starting ping finder...')).toBeInTheDocument() + expect(screen.getByText('This may take a few seconds')).toBeInTheDocument() + }) + + it('calls start function when button is clicked', async () => { + const startMock = vi.fn() + const mockContext = { + ...baseContext, + start: startMock + } + + render( + + + + ) + + const startButton = screen.getByRole('button', { name: /start/i }) + await userEvent.click(startButton) + expect(startMock).toHaveBeenCalled() + }) +}) diff --git a/frontend/tests/components/device/Stop.test.tsx b/frontend/tests/components/device/Stop.test.tsx new file mode 100644 index 0000000..8b4962b --- /dev/null +++ b/frontend/tests/components/device/Stop.test.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Stop from '../../../src/components/device/cards/Stop' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('Stop component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.STOP_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 4, + pingTime: 100, + gpsFrequency: 1, + connectionStatus: 1, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + it('renders stop button in input state', () => { + render( + + + + ) + + const stopButton = screen.getByRole('button', { name: /stop/i }) + expect(stopButton).toBeInTheDocument() + expect(stopButton).toBeEnabled() + }) + + it('disables stop button in waiting state', () => { + const mockContext = { + ...baseContext, + gcsState: GCSState.STOP_WAITING + } + + render( + + + + ) + + expect(screen.getByText('Stopping ping finder...')).toBeInTheDocument() + expect(screen.getByText('This may take a few seconds')).toBeInTheDocument() + }) + + it('calls stop function when button is clicked', async () => { + const stopMock = vi.fn() + const mockContext = { + ...baseContext, + stop: stopMock + } + + render( + + + + ) + + const stopButton = screen.getByRole('button', { name: /stop/i }) + await userEvent.click(stopButton) + expect(stopMock).toHaveBeenCalled() + }) +}) diff --git a/frontend/tests/components/manager/FrequencyLayersControl.test.tsx b/frontend/tests/components/manager/FrequencyLayersControl.test.tsx new file mode 100644 index 0000000..dfa70fb --- /dev/null +++ b/frontend/tests/components/manager/FrequencyLayersControl.test.tsx @@ -0,0 +1,156 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import FrequencyLayersControl from '../../../src/components/manager/cards/FrequencyLayersControl' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import type { PingData } from '../../../src/types/global' +import { describe, expect, it, vi } from 'vitest' + +describe('FrequencyLayersControl component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 0, + pingTime: 0, + gpsFrequency: 0, + connectionStatus: 0, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + const mockPing: PingData = { + frequency: 150000, + amplitude: 0.5, + lat: 32.88, + long: -117.234, + timestamp: Date.now() * 1000, + packet_id: 1 + } + + it('shows empty state when no frequencies exist', () => { + render( + + + + ) + expect(screen.getByRole('heading', { name: /frequency layers/i })).toBeInTheDocument() + expect(screen.getByText(/no frequencies/i)).toBeInTheDocument() + }) + + it('displays frequency entry when data exists', () => { + const mockContext = { + ...baseContext, + frequencyData: { + 150000: { pings: [mockPing], locationEstimate: null } + }, + frequencyVisibility: [ + { frequency: 150000, visible_pings: true, visible_location_estimate: true } + ] + } + + render( + + + + ) + expect(screen.getByText(/0\.150|150\.000/i)).toBeInTheDocument() + expect(screen.getByText(/mhz/i)).toBeInTheDocument() + }) + + it('deletes frequency when clear button is clicked', async () => { + const deleteMock = vi.fn() + const mockContext = { + ...baseContext, + frequencyVisibility: [ + { frequency: 150000, visible_pings: true, visible_location_estimate: true } + ], + frequencyData: { + 150000: { pings: [mockPing], locationEstimate: null } + }, + deleteFrequencyLayer: deleteMock + } + + render( + + + + ) + + const clearButton = screen.getByRole('button', { name: /clear|delete|remove/i }) + await userEvent.click(clearButton) + expect(deleteMock).toHaveBeenCalledWith(150000) + }) +}) diff --git a/frontend/tests/components/map/MapOverlayControls.test.tsx b/frontend/tests/components/map/MapOverlayControls.test.tsx new file mode 100644 index 0000000..4a7fa03 --- /dev/null +++ b/frontend/tests/components/map/MapOverlayControls.test.tsx @@ -0,0 +1,125 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MapOverlayControls from '../../../src/components/map/MapOverlayControls' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { describe, expect, it, vi } from 'vitest' + +describe('MapOverlayControls component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: null }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 0, + pingTime: 0, + gpsFrequency: 0, + connectionStatus: 0, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + it('renders map source selector', () => { + render( + + + + ) + expect(screen.getByTitle('Map Style')).toBeInTheDocument() + }) + + it('changes map source when selected', async () => { + const setCurrentMapSourceMock = vi.fn() + const mockContext = { + ...baseContext, + setCurrentMapSource: setCurrentMapSourceMock + } + + render( + + + + ) + + // Click the Map Style button to show the selector + await userEvent.click(screen.getByTitle('Map Style')) + + // Now select the map source + const select = screen.getByRole('combobox') + await userEvent.selectOptions(select, 'osm') + expect(setCurrentMapSourceMock).toHaveBeenCalledWith(expect.objectContaining({ + id: 'osm' + })) + }) +}) diff --git a/frontend/tests/components/poi/POIForm.test.tsx b/frontend/tests/components/poi/POIForm.test.tsx new file mode 100644 index 0000000..ad9215e --- /dev/null +++ b/frontend/tests/components/poi/POIForm.test.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import POIForm from '../../../src/components/poi/POIForm' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { Map, LatLng } from 'leaflet' +import { describe, expect, it, vi } from 'vitest' + +describe('POIForm component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { + current: { + getCenter: () => ({ lat: 32.88, lng: -117.234 } as LatLng) + } as unknown as Map + }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 4, + pingTime: 100, + gpsFrequency: 1, + connectionStatus: 1, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + it('renders POI form with input field', () => { + render( + + + + ) + + const input = screen.getByPlaceholderText(/name/i) + expect(input).toBeInTheDocument() + }) + + it('adds POI when form is submitted', async () => { + const addPOIMock = vi.fn() + const mockContext = { + ...baseContext, + addPOI: addPOIMock + } + + render( + + + + ) + + const input = screen.getByPlaceholderText(/name/i) + await userEvent.type(input, 'Test POI') + + const addButton = screen.getByRole('button', { name: /add/i }) + await userEvent.click(addButton) + + expect(addPOIMock).toHaveBeenCalledWith('Test POI', [32.88, -117.234]) + }) + + it('clears input after successful submission', async () => { + const addPOIMock = vi.fn().mockResolvedValue(true) + const mockContext = { + ...baseContext, + addPOI: addPOIMock + } + + render( + + + + ) + + const input = screen.getByPlaceholderText(/name/i) + await userEvent.type(input, 'Test POI') + + const addButton = screen.getByRole('button', { name: /add/i }) + await userEvent.click(addButton) + + expect(input).toHaveValue('') + }) +}) diff --git a/frontend/tests/components/poi/POIList.test.tsx b/frontend/tests/components/poi/POIList.test.tsx new file mode 100644 index 0000000..907756e --- /dev/null +++ b/frontend/tests/components/poi/POIList.test.tsx @@ -0,0 +1,179 @@ +import React from 'react' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import POIList from '../../../src/components/poi/POIList' +import { GlobalAppContext } from '../../../src/context/globalAppContextDef' +import { GlobalAppState, GCSState } from '../../../src/context/globalAppTypes' +import { Map } from 'leaflet' +import type { POI } from '../../../src/types/global' +import { describe, expect, it, vi } from 'vitest' + +describe('POIList component', () => { + const baseContext: GlobalAppState = { + gcsState: GCSState.RADIO_CONFIG_INPUT, + radioConfig: { + interface_type: 'serial', + serialPorts: [], + selectedPort: '', + baudRate: 115200, + host: 'localhost', + tcpPort: 50000, + ackTimeout: 2000, + maxRetries: 5 + }, + pingFinderConfig: { + gain: 56.0, + samplingRate: 2500000, + centerFrequency: 173500000, + enableTestData: false, + pingWidthMs: 25, + pingMinSnr: 25, + pingMaxLenMult: 1.5, + pingMinLenMult: 0.5, + targetFrequencies: [] + }, + isMapOffline: false, + setIsMapOfflineUser: vi.fn(), + currentMapSource: { + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }, + setCurrentMapSource: vi.fn(), + mapSources: [{ + id: 'osm', + name: 'OpenStreetMap', + attribution: '© OpenStreetMap contributors', + minZoom: 0, + maxZoom: 19 + }], + tileInfo: null, + pois: [], + frequencyData: {}, + deleteFrequencyLayer: vi.fn(), + deleteAllFrequencyLayers: vi.fn(), + frequencyVisibility: [], + setFrequencyVisibility: vi.fn(), + mapRef: { current: { setView: vi.fn() } as unknown as Map }, + loadPOIs: vi.fn(), + addPOI: vi.fn(), + removePOI: vi.fn(), + clearTileCache: vi.fn(), + gpsData: null, + gpsDataUpdated: false, + setGpsDataUpdated: vi.fn(), + setRadioConfig: vi.fn(), + loadSerialPorts: vi.fn(), + sendRadioConfig: vi.fn(), + cancelRadioConfig: vi.fn(), + setPingFinderConfig: vi.fn(), + sendPingFinderConfig: vi.fn(), + cancelPingFinderConfig: vi.fn(), + start: vi.fn(), + cancelStart: vi.fn(), + stop: vi.fn(), + cancelStop: vi.fn(), + disconnect: vi.fn(), + connectionQuality: 4, + pingTime: 100, + gpsFrequency: 1, + connectionStatus: 1, + message: '', + messageVisible: false, + messageType: 'success', + setupStateHandlers: vi.fn(), + setMessageVisible: vi.fn(), + setGcsState: vi.fn(), + initSimulator: vi.fn(), + cleanupSimulator: vi.fn(), + isSimulatorRunning: false, + fatalError: false + } + + const mockPOIs: POI[] = [ + { name: 'Location A', coords: [32.88, -117.234] }, + { name: 'Location B', coords: [32.89, -117.235] } + ] + + it('renders empty state when no POIs exist', () => { + render( + + + + ) + + expect(screen.getByText(/no locations added yet/i)).toBeInTheDocument() + }) + + it('renders list of POIs when they exist', () => { + const mockContext = { + ...baseContext, + pois: mockPOIs + } + + render( + + + + ) + + expect(screen.getByText('Location A')).toBeInTheDocument() + expect(screen.getByText('Location B')).toBeInTheDocument() + }) + + it('calls removePOI when delete button is clicked', async () => { + const removePOIMock = vi.fn() + const mockContext = { + ...baseContext, + pois: mockPOIs, + removePOI: removePOIMock + } + + render( + + + + ) + + // First find the POI container by its text content + const poiContainer = screen.getByText(/location a/i).closest('.flex.items-center.gap-2.p-2') as HTMLDivElement + const removeButton = within(poiContainer).getByTitle('Remove') + await userEvent.click(removeButton) + + expect(removePOIMock).toHaveBeenCalledWith('Location A') + }) + + it('centers map on POI when clicked', async () => { + const setViewMock = vi.fn() + const mockMapRef = { + current: { + setView: setViewMock, + getZoom: () => 13 + } as unknown as Map + } + + const mockContext = { + ...baseContext, + mapRef: mockMapRef, + pois: mockPOIs + } + + render( + + + + ) + + // First find the POI container by its text content + const poiContainer = screen.getByText('Location A').closest('.flex.items-center.gap-2.p-2') as HTMLDivElement + expect(poiContainer).toBeInTheDocument() + + // Then find the "Go to location" button within that container + const goToButton = within(poiContainer).getByTitle('Go to location') + await userEvent.click(goToButton) + + expect(setViewMock).toHaveBeenCalledWith([32.88, -117.234], 13) + }) +}) diff --git a/frontend/tests/vitest.setup.ts b/frontend/tests/vitest.setup.ts new file mode 100644 index 0000000..010b0b5 --- /dev/null +++ b/frontend/tests/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..50e957b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "ESNext", + "DOM", + "DOM.Iterable", + "WebWorker" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..862dfb2 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9e5631d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,23 @@ +import { mergeConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const viteConfig = { + plugins: [react()], + base: './', + build: { + outDir: 'dist', + assetsDir: 'assets', + emptyOutDir: true, + sourcemap: true + } +}; + +const vitestConfig = { + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./tests/vitest.setup.ts'] + } +}; + +export default mergeConfig(viteConfig, vitestConfig); \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a6b56bf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[tool.poetry] +name = "radio-telemetry-tracker-drone-gcs" +version = "0.1.0" +description = "Radio Telemetry Tracker Drone Ground Control Station" +authors = ["Tyler Flar "] +readme = "README.md" +packages = [ + { include = "radio_telemetry_tracker_drone_gcs" }, + { include = "scripts" }, +] + +[tool.poetry.dependencies] +python = ">=3.13,<3.14" +pyqt6 = "^6.8.0" +pyqt6-webengine = "^6.8.0" +requests = "^2.32.3" +radio-telemetry-tracker-drone-comms-package = {git = "https://github.com/UCSD-E4E/radio-telemetry-tracker-drone-comms-package.git"} +werkzeug = "^3.1.3" +pyproj = "^3.7.0" +scipy = "^1.15.1" + +[tool.poetry.group.dev.dependencies] +ruff = "^0.8.4" +pytest = "^8.3.4" +pyinstaller = "^6.11.1" +pytest-qt = "^4.4.0" + +[tool.poetry.scripts] +rtt-gcs-dev = "scripts.dev:main" +rtt-gcs-build = "scripts.build:main" + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["ALL"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--maxfail=5 --tb=short" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/radio_telemetry_tracker_drone_gcs/__init__.py b/radio_telemetry_tracker_drone_gcs/__init__.py new file mode 100644 index 0000000..f113d0a --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/__init__.py @@ -0,0 +1,3 @@ +"""Ground Control Station for Radio Telemetry Tracker drone operations and data visualization.""" + +__version__ = "0.1.0" diff --git a/radio_telemetry_tracker_drone_gcs/comms/__init__.py b/radio_telemetry_tracker_drone_gcs/comms/__init__.py new file mode 100644 index 0000000..cf2ee7a --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/comms/__init__.py @@ -0,0 +1 @@ +"""Communication package for interfacing with the drone and handling telemetry data.""" diff --git a/radio_telemetry_tracker_drone_gcs/comms/communication_bridge.py b/radio_telemetry_tracker_drone_gcs/comms/communication_bridge.py new file mode 100644 index 0000000..3bcf489 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/comms/communication_bridge.py @@ -0,0 +1,761 @@ +"""Bridge module for handling communication between Qt frontend and drone backend. + +Provides a Qt-based interface for drone operations, tile management, and POI handling. +""" + +from __future__ import annotations + +import base64 +import logging +import time +from typing import Any + +import pyproj +from PyQt6.QtCore import QObject, QTimer, QVariant, pyqtSignal, pyqtSlot +from radio_telemetry_tracker_drone_comms_package import ( + ConfigRequestData, + ConfigResponseData, + ErrorData, + GPSData, + LocEstData, + PingData, + RadioConfig, + StartResponseData, + StopResponseData, + SyncResponseData, +) + +from radio_telemetry_tracker_drone_gcs.comms.drone_comms_service import DroneCommsService +from radio_telemetry_tracker_drone_gcs.comms.state_machine import DroneState, DroneStateMachine, StateTransition +from radio_telemetry_tracker_drone_gcs.data.drone_data_manager import DroneDataManager +from radio_telemetry_tracker_drone_gcs.data.models import ( + GpsData as InternalGpsData, +) +from radio_telemetry_tracker_drone_gcs.data.models import ( + LocEstData as InternalLocEstData, +) +from radio_telemetry_tracker_drone_gcs.data.models import ( + PingData as InternalPingData, +) +from radio_telemetry_tracker_drone_gcs.services.poi_service import PoiService +from radio_telemetry_tracker_drone_gcs.services.simulator_service import SimulatorService +from radio_telemetry_tracker_drone_gcs.services.tile_service import TileService + +logger = logging.getLogger(__name__) + + +class CommunicationBridge(QObject): + """Bridge between Qt frontend and drone communications backend, handling all drone-related operations.""" + + # Sync/Connect + sync_success = pyqtSignal(str) + sync_failure = pyqtSignal(str) + sync_timeout = pyqtSignal() + + # Config + config_success = pyqtSignal(str) + config_failure = pyqtSignal(str) + config_timeout = pyqtSignal() + + # Start + start_success = pyqtSignal(str) + start_failure = pyqtSignal(str) + start_timeout = pyqtSignal() + + # Stop + stop_success = pyqtSignal(str) + stop_failure = pyqtSignal(str) + stop_timeout = pyqtSignal() + + # Disconnect + disconnect_success = pyqtSignal(str) + disconnect_failure = pyqtSignal(str) + + # Fatal error + fatal_error = pyqtSignal() + + # Tile & POI signals + tile_info_updated = pyqtSignal(QVariant) + pois_updated = pyqtSignal(QVariant) + + # GPS, Ping, LocEst + gps_data_updated = pyqtSignal(QVariant) + frequency_data_updated = pyqtSignal(QVariant) + + # Simulator + simulator_started = pyqtSignal() + simulator_stopped = pyqtSignal() + + def __init__(self) -> None: + """Initialize the communication bridge with data manager and services.""" + super().__init__() + + self._drone_data_manager = DroneDataManager() + self._drone_data_manager.gps_data_updated.connect(self.gps_data_updated.emit) + self._drone_data_manager.frequency_data_updated.connect(self.frequency_data_updated.emit) + + # Tile & POI + self._tile_service = TileService() + self._poi_service = PoiService() + + # State machine + self._state_machine = DroneStateMachine() + self._state_machine.state_error.connect(self.fatal_error.emit) + self._setup_state_handlers() + + # Comms + self._comms_service: DroneCommsService | None = None + self._sync_response_received: bool = False + self._config_response_received: bool = False + self._start_response_received: bool = False + self._stop_response_received: bool = False + self._disconnect_response_received: bool = False + + # Simulator + self._simulator_service: SimulatorService | None = None + + def _setup_state_handlers(self) -> None: + """Set up state machine handlers.""" + # Radio config handlers + self._state_machine.register_transition_handler( + DroneState.RADIO_CONFIG_WAITING, + lambda: self._comms_service.register_sync_response_handler(self._on_sync_response, once=True), + ) + + # Ping finder config handlers + self._state_machine.register_transition_handler( + DroneState.PING_FINDER_CONFIG_WAITING, + lambda: self._comms_service.register_config_response_handler(self._on_config_response, once=True), + ) + + # Start handlers + self._state_machine.register_transition_handler( + DroneState.START_WAITING, + lambda: self._comms_service.register_start_response_handler(self._on_start_response, once=True), + ) + + # Stop handlers + self._state_machine.register_transition_handler( + DroneState.STOP_WAITING, + lambda: self._comms_service.register_stop_response_handler(self._on_stop_response, once=True), + ) + + # Register timeout handlers + self._state_machine.register_timeout_handler( + DroneState.RADIO_CONFIG_WAITING, + lambda: self.sync_timeout.emit(), + ) + self._state_machine.register_timeout_handler( + DroneState.PING_FINDER_CONFIG_WAITING, + lambda: self.config_timeout.emit(), + ) + self._state_machine.register_timeout_handler( + DroneState.START_WAITING, + lambda: self.start_timeout.emit(), + ) + self._state_machine.register_timeout_handler( + DroneState.STOP_WAITING, + lambda: self.stop_timeout.emit(), + ) + + # -------------------------------------------------------------------------- + # Basic slots for comms + # -------------------------------------------------------------------------- + + @pyqtSlot(result="QVariantList") + def get_serial_ports(self) -> list[str]: + """Return a list of available serial port device names.""" + import serial.tools.list_ports + + port_info = list(serial.tools.list_ports.comports()) + return [str(p.device) for p in port_info] + + @pyqtSlot("QVariantMap", result=bool) + def initialize_comms(self, config: dict[str, Any]) -> bool: + """Initialize drone communications with the given configuration. + + Args: + config: Dictionary containing radio and acknowledgment settings. + + Returns: + bool: True if initialization succeeded, False otherwise. + """ + try: + radio_cfg = RadioConfig( + interface_type=config["interface_type"], + port=config["port"], + baudrate=int(config["baudrate"]), + host=config["host"], + tcp_port=int(config["tcp_port"]), + server_mode=False, + ) + ack_s = float(config["ack_timeout"]) + max_r = int(config["max_retries"]) + + self._comms_service = DroneCommsService( + radio_config=radio_cfg, + ack_timeout=ack_s, + max_retries=max_r, + on_ack_success=self._on_ack_success, + on_ack_timeout=self._on_ack_timeout, + ) + self._comms_service.start() + + # Register packet handlers + self._comms_service.register_error_handler(self._handle_error_packet) + + # Transition to connecting state + self._state_machine.transition_to( + DroneState.RADIO_CONFIG_WAITING, + StateTransition( + from_state=DroneState.RADIO_CONFIG_INPUT, + to_state=DroneState.RADIO_CONFIG_WAITING, + success_message="Drone connected successfully", + failure_message="Failed to connect to drone", + ), + ) + + # Send sync + self._comms_service.send_sync_request() + self._sync_response_received = False + + tt = ack_s * max_r + QTimer.singleShot(int(tt * 1000), self._sync_timeout_check) + except Exception as e: + logging.exception("Error in initialize_comms") + self.sync_failure.emit(f"Initialize comms failed: {e!s}") + return False + else: + return True + + @pyqtSlot() + def cancel_connection(self) -> None: + """User cancels sync/connect attempt.""" + if self._comms_service: + self._comms_service.stop() + self._comms_service = None + self._state_machine.transition_to(DroneState.RADIO_CONFIG_INPUT) + + @pyqtSlot() + def disconnect(self) -> None: + """Disconnect from the drone and clean up communication resources.""" + if not self._comms_service: + self.disconnect_success.emit("UNDEFINED BEHAVIOR: Not Connected.") + return + try: + self._comms_service.register_stop_response_handler(self._on_disconnect_response, once=True) + self._comms_service.send_stop_request() + self._disconnect_response_received = False + + tt = self._comms_service.ack_timeout * self._comms_service.max_retries + QTimer.singleShot(int(tt * 1000), self._disconnect_timeout_check) + except Exception: + logging.exception("Stop request failed => forcing cleanup.") + self.disconnect_failure.emit("Stop request failed... forcing cleanup.") + self._cleanup() + + # -------------------------------------------------------------------------- + # Config + # -------------------------------------------------------------------------- + @pyqtSlot("QVariantMap", result=bool) + def send_config_request(self, cfg: dict[str, Any]) -> bool: + """Send config => wait => user can cancel => if ack fails => config_timout.""" + if not self._comms_service: + self.config_failure.emit("UNDEFINED BEHAVIOR: Not Connected.") + return False + + try: + req = ConfigRequestData( + gain=float(cfg["gain"]), + sampling_rate=int(cfg["sampling_rate"]), + center_frequency=int(cfg["center_frequency"]), + run_num=int(time.time()), + enable_test_data=bool(cfg["enable_test_data"]), + ping_width_ms=int(cfg["ping_width_ms"]), + ping_min_snr=int(cfg["ping_min_snr"]), + ping_max_len_mult=float(cfg["ping_max_len_mult"]), + ping_min_len_mult=float(cfg["ping_min_len_mult"]), + target_frequencies=list(map(int, cfg["target_frequencies"])), + ) + self._comms_service.register_config_response_handler(self._on_config_response, once=True) + self._comms_service.send_config_request(req) + self._config_response_received = False + + tt = self._comms_service.ack_timeout * self._comms_service.max_retries + QTimer.singleShot(int(tt * 1000), self._config_timeout_check) + except Exception as e: + logging.exception("Error in send_config_request") + self.config_failure.emit(str(e)) + return False + else: + return True + + @pyqtSlot(result=bool) + def cancel_config_request(self) -> bool: + """Cancel the config request.""" + if not self._comms_service: + self.config_failure.emit("UNDEFINED BEHAVIOR: Not Connected.") + return False + self._comms_service.unregister_config_response_handler(self._on_config_response) + return True + + # -------------------------------------------------------------------------- + # Start + # -------------------------------------------------------------------------- + @pyqtSlot(result=bool) + def send_start_request(self) -> bool: + """Send start request => wait => user can cancel => if ack fails => start_timeout.""" + if not self._comms_service: + self.start_failure.emit("UNDEFINED BEHAVIOR: Not Connected.") + return False + + try: + self._comms_service.register_start_response_handler(self._on_start_response, once=True) + self._comms_service.send_start_request() + self._start_response_received = False + + tt = self._comms_service.ack_timeout * self._comms_service.max_retries + QTimer.singleShot(int(tt * 1000), self._start_timeout_check) + except Exception as e: + logging.exception("Error in send_start_request") + self.start_failure.emit(str(e)) + return False + else: + return True + + @pyqtSlot(result=bool) + def cancel_start_request(self) -> bool: + """Cancel the start request.""" + if not self._comms_service: + self.start_failure.emit("UNDEFINED BEHAVIOR: Not Connected.") + return False + self._comms_service.unregister_start_response_handler(self._on_start_response) + return True + + # -------------------------------------------------------------------------- + # Stop + # -------------------------------------------------------------------------- + @pyqtSlot(result=bool) + def send_stop_request(self) -> bool: + """Send stop request => wait => user can cancel => if ack fails => stop_timeout.""" + if not self._comms_service: + self.stop_failure.emit("UNDEFINED BEHAVIOR: Not Connected.") + return False + + try: + self._comms_service.register_stop_response_handler(self._on_stop_response, once=True) + self._comms_service.send_stop_request() + self._stop_response_received = False + + tt = self._comms_service.ack_timeout * self._comms_service.max_retries + QTimer.singleShot(int(tt * 1000), self._stop_timeout_check) + except Exception as e: + logging.exception("Error in send_stop_request") + self.stop_failure.emit(str(e)) + return False + else: + return True + + @pyqtSlot(result=bool) + def cancel_stop_request(self) -> bool: + """Cancel the stop request.""" + if not self._comms_service: + self.stop_failure.emit("UNDEFINED BEHAVIOR: Not Connected.") + return False + self._comms_service.unregister_stop_response_handler(self._on_stop_response) + return True + + # -------------------------------------------------------------------------- + # GPS, Ping, LocEst + # -------------------------------------------------------------------------- + def _handle_gps_data(self, gps: GPSData) -> None: + lat, lng = self._transform_coords(gps.easting, gps.northing, gps.epsg_code) + internal_gps = InternalGpsData( + lat=lat, + long=lng, + altitude=gps.altitude, + heading=gps.heading, + timestamp=gps.timestamp, + packet_id=gps.packet_id, + ) + self._drone_data_manager.update_gps(internal_gps) + + def _handle_ping_data(self, ping: PingData) -> None: + """Handle ping data from drone.""" + try: + # Validate ping data + if not all( + hasattr(ping, attr) + for attr in ["easting", "northing", "epsg_code", "frequency", "amplitude", "timestamp", "packet_id"] + ): + logger.error("Invalid ping data received: missing required attributes") + return + + lat, lng = self._transform_coords(ping.easting, ping.northing, ping.epsg_code) + logger.info( + "Ping data received - Freq: %d Hz, Amplitude: %.2f dB, UTM: (%.2f, %.2f) -> LatLng: (%.6f, %.6f)", + ping.frequency, + ping.amplitude, + ping.easting, + ping.northing, + lat, + lng, + ) + internal_ping = InternalPingData( + frequency=ping.frequency, + amplitude=ping.amplitude, + lat=lat, + long=lng, + timestamp=ping.timestamp, + packet_id=ping.packet_id, + ) + self._drone_data_manager.add_ping(internal_ping) + except Exception: + logger.exception("Error handling ping data") + + def _handle_loc_est_data(self, loc_est: LocEstData) -> None: + """Handle location estimate data from drone.""" + lat, lng = self._transform_coords(loc_est.easting, loc_est.northing, loc_est.epsg_code) + internal_loc_est = InternalLocEstData( + frequency=loc_est.frequency, + lat=lat, + long=lng, + timestamp=loc_est.timestamp, + packet_id=loc_est.packet_id, + ) + logger.info( + "Location estimate received - Freq: %d Hz, Position: (%.6f, %.6f)", + loc_est.frequency, + lat, + lng, + ) + self._drone_data_manager.update_loc_est(internal_loc_est) + + # -------------------------------------------------------------------------- + # Error + # -------------------------------------------------------------------------- + def _handle_error_packet(self, _: ErrorData) -> None: + logging.error("Received fatal error packet") + self.fatal_error.emit() + + # -------------------------------------------------------------------------- + # Tile & POI bridging + # -------------------------------------------------------------------------- + @pyqtSlot("int", "int", "int", "QString", "QVariantMap", result="QString") + def get_tile(self, z: int, x: int, y: int, source: str, options: dict) -> str: + """Get map tile data for the specified coordinates and zoom level. + + Args: + z: Zoom level + x: X coordinate + y: Y coordinate + source: Tile source identifier + options: Additional options including offline mode + + Returns: + Base64 encoded tile data or empty string on error + """ + try: + offline = bool(options["offline"]) + tile_data = self._tile_service.get_tile(z, x, y, source_id=source, offline=offline) + if not tile_data: + return "" + # We can update tile info + info = self._tile_service.get_tile_info() + self.tile_info_updated.emit(QVariant(info)) + return base64.b64encode(tile_data).decode("utf-8") + except Exception: + logging.exception("Error in get_tile()") + return "" + + @pyqtSlot(result=QVariant) + def get_tile_info(self) -> QVariant: + """Get information about the current tile cache state.""" + try: + info = self._tile_service.get_tile_info() + return QVariant(info) + except Exception: + logging.exception("Error in get_tile_info()") + return QVariant({}) + + @pyqtSlot(result=bool) + def clear_tile_cache(self) -> bool: + """Clear the map tile cache and return success status.""" + try: + return self._tile_service.clear_tile_cache() + except Exception: + logging.exception("Error clearing tile cache") + return False + + @pyqtSlot(result="QVariant") + def get_pois(self) -> list[dict]: + """Get list of all points of interest (POIs) in the system.""" + try: + return self._poi_service.get_pois() + except Exception: + logging.exception("Error getting POIs") + return [] + + @pyqtSlot(str, "QVariantList", result=bool) + def add_poi(self, name: str, coords: list[float]) -> bool: + """Add a new point of interest with the given name and coordinates.""" + try: + self._poi_service.add_poi(name, coords) + self._emit_pois() + except Exception: + logging.exception("Error adding POI") + return False + else: + return True + + @pyqtSlot(str, result=bool) + def remove_poi(self, name: str) -> bool: + """Remove a point of interest with the specified name.""" + try: + self._poi_service.remove_poi(name) + self._emit_pois() + except Exception: + logging.exception("Error removing POI") + return False + else: + return True + + @pyqtSlot(str, str, result=bool) + def rename_poi(self, old_name: str, new_name: str) -> bool: + """Rename a point of interest from old_name to new_name.""" + try: + self._poi_service.rename_poi(old_name, new_name) + self._emit_pois() + except Exception: + logging.exception("Error renaming POI") + return False + else: + return True + + def _emit_pois(self) -> None: + pois = self._poi_service.get_pois() + self.pois_updated.emit(QVariant(pois)) + + # -------------------------------------------------------------------------- + # LAYERS + # -------------------------------------------------------------------------- + @pyqtSlot(int, result=bool) + def clear_frequency_data(self, frequency: int) -> bool: + """Clear all data for the specified frequency and return success status.""" + try: + self._drone_data_manager.clear_frequency_data(frequency) + except Exception: + logging.exception("Error clearing frequency data") + return False + else: + return True + + @pyqtSlot(int, result=bool) + def clear_all_frequency_data(self) -> bool: + """Clear all frequency-related data across all frequencies and return success status.""" + try: + self._drone_data_manager.clear_all_frequency_data() + except Exception: + logging.exception("Error clearing all frequency data") + return False + else: + return True + + # -------------------------------------------------------------------------- + # TIMEOUTS + # -------------------------------------------------------------------------- + def _sync_timeout_check(self) -> None: + if not self._sync_response_received: + logging.warning("Sync response not received => sync_timeout.") + self.sync_timeout.emit() + self._sync_response_received = True + + def _config_timeout_check(self) -> None: + if not self._config_response_received: + logging.warning("Config response not received => config_timeout.") + self.config_timeout.emit() + self._config_response_received = True + + def _start_timeout_check(self) -> None: + if not self._start_response_received: + logging.warning("Start response not received => start_timeout.") + self.start_timeout.emit() + self._start_response_received = True + + def _stop_timeout_check(self) -> None: + if not self._stop_response_received: + logging.warning("Stop response not received => stop_timeout.") + self.stop_timeout.emit() + self._stop_response_received = True + + def _disconnect_timeout_check(self) -> None: + if not self._disconnect_response_received: + logging.warning("Stop response not received => forcibly cleanup => disconnect_timeout.") + self.disconnect_failure.emit("Stop response not received => forcibly cleanup => disconnect_timeout.") + self._cleanup() + self._disconnect_response_received = True + + # -------------------------------------------------------------------------- + # RESPONSES + # -------------------------------------------------------------------------- + def _on_sync_response(self, rsp: SyncResponseData) -> None: + """Handle sync response from drone.""" + self._sync_response_received = True + + if not rsp.success: + logging.warning("Sync success=False => Undefined behavior") + self.sync_failure.emit("UNDEFINED BEHAVIOR: Sync failed.") + self._state_machine.transition_to(DroneState.ERROR) + return + + self.sync_success.emit("Successfully connected to drone.") + self._comms_service.register_gps_handler(self._handle_gps_data, once=False) + self._state_machine.transition_to(DroneState.PING_FINDER_CONFIG_INPUT) + + def _on_config_response(self, rsp: ConfigResponseData) -> None: + """Handle config response from drone.""" + self._config_response_received = True + + if not rsp.success: + logging.warning("Config success=False => Undefined behavior") + self.config_failure.emit("UNDEFINED BEHAVIOR: Config failed.") + self._state_machine.transition_to(DroneState.ERROR) + return + + self.config_success.emit("Config sent to drone.") + self._state_machine.transition_to(DroneState.START_INPUT) + + def _on_start_response(self, rsp: StartResponseData) -> None: + """Handle start response from drone.""" + self._start_response_received = True + + if not rsp.success: + logging.warning("Start success=False => Improper state.") + self.start_failure.emit("UNDEFINED BEHAVIOR: Improper state.") + self._state_machine.transition_to(DroneState.ERROR) + return + + self.start_success.emit("Drone is now starting.") + self._comms_service.register_ping_handler(self._handle_ping_data, once=False) + self._comms_service.register_loc_est_handler(self._handle_loc_est_data, once=False) + self._state_machine.transition_to(DroneState.STOP_INPUT) + + def _on_stop_response(self, rsp: StopResponseData) -> None: + """Handle stop response from drone.""" + self._stop_response_received = True + + if not rsp.success: + logging.warning("Stop success=False => Improper state.") + self.stop_failure.emit("UNDEFINED BEHAVIOR: Improper state.") + self._state_machine.transition_to(DroneState.ERROR) + return + + self.stop_success.emit("Drone is now stopping.") + self._comms_service.unregister_ping_handler(self._handle_ping_data) + self._comms_service.unregister_loc_est_handler(self._handle_loc_est_data) + self._state_machine.transition_to(DroneState.PING_FINDER_CONFIG_INPUT) + + def _on_disconnect_response(self, rsp: StopResponseData) -> None: + """Handle disconnect response from drone.""" + self._disconnect_response_received = True + + if not rsp.success: + logging.warning("Disconnect success=False => Improper state.") + self.disconnect_failure.emit("UNDEFINED BEHAVIOR: Improper state.") + self._state_machine.transition_to(DroneState.ERROR) + return + + self.disconnect_success.emit("Drone is now disconnected.") + self._comms_service.unregister_gps_handler(self._handle_gps_data) + self._cleanup() + self._state_machine.transition_to(DroneState.RADIO_CONFIG_INPUT) + + # -------------------------------------------------------------------------- + # Ack callbacks from DroneComms + # -------------------------------------------------------------------------- + def _on_ack_success(self, packet_id: int) -> None: + logging.info("Packet %d ack success", packet_id) + + def _on_ack_timeout(self, packet_id: int) -> None: + logging.warning("Ack timeout for packet %d", packet_id) + + # -------------------------------------------------------------------------- + # UTILS + # -------------------------------------------------------------------------- + def _cleanup(self) -> None: + if self._comms_service: + self._comms_service.stop() + self._comms_service = None + self._state_machine.transition_to(DroneState.RADIO_CONFIG_INPUT) + self.disconnect_success.emit("Disconnected") + + def _transform_coords(self, easting: float, northing: float, epsg_code: int) -> tuple[float, float]: + epsg_str = str(epsg_code) + zone = epsg_str[-2:] + hemisphere = "north" if epsg_str[-3] == "6" else "south" + + utm_proj = pyproj.Proj(proj="utm", zone=zone, ellps="WGS84", hemisphere=hemisphere) + wgs84_proj = pyproj.Proj("epsg:4326") + transformer = pyproj.Transformer.from_proj(utm_proj, wgs84_proj, always_xy=True) + lng, lat = transformer.transform(easting, northing) + return (lat, lng) + + # Add logging method to match TypeScript interface + @pyqtSlot(str) + def log_message(self, message: str) -> None: + """Log a message from the frontend.""" + logging.info("Frontend log: %s", message) + + # -------------------------------------------------------------------------- + # SIMULATOR + # -------------------------------------------------------------------------- + @pyqtSlot("QVariantMap", result=bool) + def init_simulator(self, config: dict[str, Any]) -> bool: + """Initialize the simulator with the given radio configuration. + + Args: + config: Dictionary containing radio configuration settings. + + Returns: + bool: True if initialization succeeded, False otherwise. + """ + try: + # Create radio config for simulator (server mode) + radio_cfg = RadioConfig( + interface_type=config["interface_type"], + port=config["port"], + baudrate=int(config["baudrate"]), + host=config["host"], + tcp_port=int(config["tcp_port"]), + server_mode=True, # Simulator acts as server + ) + + # Initialize simulator service + self._simulator_service = SimulatorService(radio_cfg) + self._simulator_service.start() + self.simulator_started.emit() + except Exception: + logging.exception("Error initializing simulator") + return False + else: + return True + + @pyqtSlot(result=bool) + def cleanup_simulator(self) -> bool: + """Stop the simulator and clean up resources. + + Returns: + bool: True if cleanup succeeded, False if simulator was not running. + """ + if not self._simulator_service: + return False + + try: + self._simulator_service.stop() + self._simulator_service = None + self.simulator_stopped.emit() + except Exception: + logging.exception("Error cleaning up simulator") + return False + else: + return True diff --git a/radio_telemetry_tracker_drone_gcs/comms/drone_comms_service.py b/radio_telemetry_tracker_drone_gcs/comms/drone_comms_service.py new file mode 100644 index 0000000..321fd77 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/comms/drone_comms_service.py @@ -0,0 +1,207 @@ +"""Wraps user-provided DroneComms, handles start/stop, sending requests.""" + +from __future__ import annotations + +from typing import Callable + +from radio_telemetry_tracker_drone_comms_package import ( + ConfigRequestData, + ConfigResponseData, + DroneComms, + ErrorData, + GPSData, + LocEstData, + PingData, + RadioConfig, + StartRequestData, + StartResponseData, + StopRequestData, + StopResponseData, + SyncRequestData, + SyncResponseData, +) + + +class DroneCommsService: + """Manages DroneComms lifecycle and sending requests (sync, config, start, stop).""" + + def __init__( + self, + radio_config: RadioConfig, + ack_timeout: float, + max_retries: int, + on_ack_success: Callable[[int], None] | None = None, + on_ack_timeout: Callable[[int], None] | None = None, + ) -> None: + """Initialize drone communications service with radio config and acknowledgment settings. + + Args: + radio_config: Radio configuration parameters + ack_timeout: Time to wait for acknowledgment + max_retries: Maximum retry attempts for failed transmissions + on_ack_success: Callback when acknowledgment received + on_ack_timeout: Callback when acknowledgment times out + """ + self.radio_config = radio_config + self.ack_timeout = ack_timeout + self.max_retries = max_retries + self._comms = DroneComms( + radio_config=radio_config, + ack_timeout=ack_timeout, + max_retries=max_retries, + on_ack_success=on_ack_success, + on_ack_timeout=on_ack_timeout, + ) + self._started = False + + def start(self) -> None: + """Start the drone communications service.""" + if not self._started: + self._comms.start() + self._started = True + + def stop(self) -> None: + """Stop the drone communications service.""" + if self._started: + self._comms.stop() + self._started = False + + def is_started(self) -> bool: + """Check if the service is started. + + Returns: + bool: True if started, False otherwise + """ + return self._started + + # Registration for packet handlers + def register_sync_response_handler( + self, + callback: Callable[[SyncResponseData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle sync response packets from the drone.""" + self._comms.register_sync_response_handler(callback, once=once) + + def unregister_sync_response_handler(self, callback: Callable[[SyncResponseData], None]) -> None: + """Unregister a callback to handle sync response packets from the drone.""" + self._comms.unregister_sync_response_handler(callback) + + def register_config_response_handler( + self, + callback: Callable[[ConfigResponseData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle config response packets from the drone.""" + self._comms.register_config_response_handler(callback, once=once) + + def unregister_config_response_handler(self, callback: Callable[[ConfigResponseData], None]) -> None: + """Unregister a callback to handle config response packets from the drone.""" + self._comms.unregister_config_response_handler(callback) + + def register_start_response_handler( + self, + callback: Callable[[StartResponseData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle start response packets from the drone.""" + self._comms.register_start_response_handler(callback, once=once) + + def unregister_start_response_handler(self, callback: Callable[[StartResponseData], None]) -> None: + """Unregister a callback to handle start response packets from the drone.""" + self._comms.unregister_start_response_handler(callback) + + def register_stop_response_handler( + self, + callback: Callable[[StopResponseData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle stop response packets from the drone.""" + self._comms.register_stop_response_handler(callback, once=once) + + def unregister_stop_response_handler(self, callback: Callable[[StopResponseData], None]) -> None: + """Unregister a callback to handle stop response packets from the drone.""" + self._comms.unregister_stop_response_handler(callback) + + def register_gps_handler( + self, + callback: Callable[[GPSData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle GPS data packets from the drone.""" + self._comms.register_gps_handler(callback, once=once) + + def unregister_gps_handler(self, callback: Callable[[GPSData], None]) -> None: + """Unregister a callback to handle GPS data packets from the drone.""" + self._comms.unregister_gps_handler(callback) + + def register_ping_handler( + self, + callback: Callable[[PingData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle ping packets from the drone.""" + self._comms.register_ping_handler(callback, once=once) + + def unregister_ping_handler(self, callback: Callable[[PingData], None]) -> None: + """Unregister a callback to handle ping packets from the drone.""" + self._comms.unregister_ping_handler(callback) + + def register_loc_est_handler( + self, + callback: Callable[[LocEstData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle location estimation data packets from the drone.""" + self._comms.register_loc_est_handler(callback, once=once) + + def unregister_loc_est_handler(self, callback: Callable[[LocEstData], None]) -> None: + """Unregister a callback to handle location estimation data packets from the drone.""" + self._comms.unregister_loc_est_handler(callback) + + def register_error_handler( + self, + callback: Callable[[ErrorData], None], + *, + once: bool = True, + ) -> None: + """Register a callback to handle error data packets from the drone.""" + self._comms.register_error_handler(callback, once=once) + + def unregister_error_handler(self, callback: Callable[[ErrorData], None]) -> None: + """Unregister a callback to handle error data packets from the drone.""" + self._comms.unregister_error_handler(callback) + + # Sending requests + def send_sync_request(self) -> int: + """Send a sync request to the drone and return the packet ID.""" + data = SyncRequestData( + ack_timeout=self.ack_timeout, + max_retries=self.max_retries, + ) + packet_id, need_ack, ts = self._comms.send_sync_request(data) + return packet_id + + def send_config_request(self, cfg: ConfigRequestData) -> int: + """Send a configuration request to the drone and return the packet ID.""" + packet_id, need_ack, ts = self._comms.send_config_request(cfg) + return packet_id + + def send_start_request(self) -> int: + """Send a start request to the drone and return the packet ID.""" + req = StartRequestData() + packet_id, need_ack, ts = self._comms.send_start_request(req) + return packet_id + + def send_stop_request(self) -> int: + """Send a stop request to the drone and return the packet ID.""" + req = StopRequestData() + packet_id, need_ack, ts = self._comms.send_stop_request(req) + return packet_id diff --git a/radio_telemetry_tracker_drone_gcs/comms/state_machine.py b/radio_telemetry_tracker_drone_gcs/comms/state_machine.py new file mode 100644 index 0000000..c0c0298 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/comms/state_machine.py @@ -0,0 +1,164 @@ +"""State machine for managing drone communication states.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum, auto +from typing import Callable + +from PyQt6.QtCore import QObject, pyqtSignal + + +class DroneState(Enum): + """Enum representing possible drone states.""" + + # Radio config states + RADIO_CONFIG_INPUT = auto() + RADIO_CONFIG_WAITING = auto() + RADIO_CONFIG_TIMEOUT = auto() + + # Ping finder config states + PING_FINDER_CONFIG_INPUT = auto() + PING_FINDER_CONFIG_WAITING = auto() + PING_FINDER_CONFIG_TIMEOUT = auto() + + # Start states + START_INPUT = auto() + START_WAITING = auto() + START_TIMEOUT = auto() + + # Stop states + STOP_INPUT = auto() + STOP_WAITING = auto() + STOP_TIMEOUT = auto() + + # Error state + ERROR = auto() + + +@dataclass +class StateTransition: + """Data class for state transition information.""" + + from_state: DroneState + to_state: DroneState + success_message: str + failure_message: str + + +class DroneStateMachine(QObject): + """State machine for managing drone communication states.""" + + # State change signals + state_changed = pyqtSignal(DroneState) + state_error = pyqtSignal(str) + + def __init__(self) -> None: + """Initialize the state machine.""" + super().__init__() + self._current_state = DroneState.RADIO_CONFIG_INPUT + self._transition_handlers: dict[DroneState, Callable[[], None]] = {} + self._error_handlers: dict[DroneState, Callable[[str], None]] = {} + self._timeout_handlers: dict[DroneState, Callable[[], None]] = {} + + @property + def current_state(self) -> DroneState: + """Get the current state.""" + return self._current_state + + def register_transition_handler( + self, + state: DroneState, + handler: Callable[[], None], + ) -> None: + """Register a handler for state transitions. + + Args: + state: The state to handle transitions for + handler: The handler function to call + """ + self._transition_handlers[state] = handler + + def register_error_handler(self, state: DroneState, handler: Callable[[str], None]) -> None: + """Register a handler for state errors. + + Args: + state: The state to handle errors for + handler: The handler function to call + """ + self._error_handlers[state] = handler + + def register_timeout_handler(self, state: DroneState, handler: Callable[[], None]) -> None: + """Register a handler for state timeouts. + + Args: + state: The state to handle timeouts for + handler: The handler function to call + """ + self._timeout_handlers[state] = handler + + def handle_timeout(self) -> None: + """Handle timeout in the current state.""" + current_state = self._current_state + + # Map waiting states to timeout states + timeout_map = { + DroneState.RADIO_CONFIG_WAITING: DroneState.RADIO_CONFIG_TIMEOUT, + DroneState.PING_FINDER_CONFIG_WAITING: DroneState.PING_FINDER_CONFIG_TIMEOUT, + DroneState.START_WAITING: DroneState.START_TIMEOUT, + DroneState.STOP_WAITING: DroneState.STOP_TIMEOUT, + } + + if current_state in timeout_map: + self.transition_to(timeout_map[current_state]) + if current_state in self._timeout_handlers: + try: + self._timeout_handlers[current_state]() + except Exception: + logging.exception("Error in timeout handler") + + def transition_to(self, new_state: DroneState, transition: StateTransition | None = None) -> None: + """Transition to a new state. + + Args: + new_state: The state to transition to + transition: Optional transition information + """ + if transition and transition.from_state != self._current_state: + error_msg = ( + f"Invalid state transition from {self._current_state} to {new_state}. " + f"Expected from state: {transition.from_state}" + ) + logging.error(error_msg) + self.state_error.emit(error_msg) + return + + old_state = self._current_state + self._current_state = new_state + logging.info("State transition: %s -> %s", old_state, new_state) + + if new_state in self._transition_handlers: + try: + self._transition_handlers[new_state]() + except Exception as e: + error_msg = f"Error in transition handler: {e}" + logging.exception(error_msg) + self.state_error.emit(error_msg) + return + + self.state_changed.emit(new_state) + + def handle_error(self, error_msg: str) -> None: + """Handle an error in the current state. + + Args: + error_msg: The error message + """ + if self._current_state in self._error_handlers: + try: + self._error_handlers[self._current_state](error_msg) + except Exception: + logging.exception("Error in error handler") + + self.state_error.emit(error_msg) diff --git a/radio_telemetry_tracker_drone_gcs/data/__init__.py b/radio_telemetry_tracker_drone_gcs/data/__init__.py new file mode 100644 index 0000000..d5040b4 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/data/__init__.py @@ -0,0 +1 @@ +"""Data models and management for drone telemetry and application state.""" diff --git a/radio_telemetry_tracker_drone_gcs/data/drone_data_manager.py b/radio_telemetry_tracker_drone_gcs/data/drone_data_manager.py new file mode 100644 index 0000000..65ae669 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/data/drone_data_manager.py @@ -0,0 +1,93 @@ +"""Manager for drone telemetry data including GPS, ping detections, and location estimates.""" + +from __future__ import annotations + +import logging +from dataclasses import asdict +from typing import TYPE_CHECKING, Any + +from PyQt6.QtCore import QObject, QVariant, pyqtSignal + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from radio_telemetry_tracker_drone_gcs.models import GpsData, LocEstData, PingData + + +class DroneDataManager(QObject): + """Manages drone telemetry data including GPS and frequency data.""" + + gps_data_updated = pyqtSignal(QVariant) + frequency_data_updated = pyqtSignal(QVariant) + + def __init__(self) -> None: + """Initialize drone data manager with empty GPS, ping, and location estimate storage.""" + super().__init__() + self._frequency_data: dict[int, dict[str, Any]] = {} + + def update_gps(self, gps: GpsData) -> None: + """Update current GPS data and emit update signal with the new data.""" + self.gps_data_updated.emit(QVariant(asdict(gps))) + + def _emit_frequency_data(self) -> None: + """Helper to emit frequency data in a consistent format.""" + data = {} + for freq, freq_data in self._frequency_data.items(): + data[str(freq)] = { + "pings": freq_data["pings"], + "locationEstimate": freq_data["locationEstimate"], + "frequency": freq, + } + self.frequency_data_updated.emit(QVariant(data)) + + def add_ping(self, ping: PingData) -> None: + """Add a new ping detection and emit update signal.""" + freq = ping.frequency + if freq not in self._frequency_data: + self._frequency_data[freq] = {"pings": [], "locationEstimate": None, "frequency": freq} + + ping_dict = asdict(ping) + self._frequency_data[freq]["pings"].append(ping_dict) + logger.info("Added ping to frequency %d Hz, total pings: %d", freq, len(self._frequency_data[freq]["pings"])) + self._emit_frequency_data() + + def update_loc_est(self, loc_est: LocEstData) -> None: + """Update location estimate for a frequency.""" + freq = loc_est.frequency + if freq not in self._frequency_data: + self._frequency_data[freq] = {"pings": [], "locationEstimate": None, "frequency": freq} + + loc_est_dict = asdict(loc_est) + self._frequency_data[freq]["locationEstimate"] = loc_est_dict + logger.info("Updated location estimate for frequency %d Hz", freq) + self._emit_frequency_data() + + def clear_frequency_data(self, frequency: int) -> None: + """Clear data for specified frequency.""" + if frequency in self._frequency_data: + del self._frequency_data[frequency] + self._emit_frequency_data() + + def clear_all_frequency_data(self) -> None: + """Clear all frequency data.""" + self._frequency_data.clear() + self._emit_frequency_data() + + def has_frequency(self, frequency: int) -> bool: + """Check if data exists for a given frequency. + + Args: + frequency: The frequency to check + + Returns: + bool: True if frequency exists in data, False otherwise + """ + return frequency in self._frequency_data + + def get_frequencies(self) -> list[int]: + """Get list of frequencies with data. + + Returns: + list[int]: List of frequencies + """ + return list(self._frequency_data.keys()) diff --git a/radio_telemetry_tracker_drone_gcs/data/models.py b/radio_telemetry_tracker_drone_gcs/data/models.py new file mode 100644 index 0000000..3d1be0f --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/data/models.py @@ -0,0 +1,35 @@ +"""Data models for internal representation of drone telemetry data.""" + +from dataclasses import dataclass + + +@dataclass +class GpsData: + """GPS position data from the drone including latitude, longitude, altitude, and heading.""" + lat: float + long: float + altitude: float + heading: float + timestamp: int + packet_id: int + + +@dataclass +class PingData: + """Radio ping detection data including frequency, amplitude, and location.""" + frequency: int + amplitude: float + lat: float + long: float + timestamp: int + packet_id: int + + +@dataclass +class LocEstData: + """Location estimate data for a specific frequency based on ping detections.""" + frequency: int + lat: float + long: float + timestamp: int + packet_id: int diff --git a/radio_telemetry_tracker_drone_gcs/main.py b/radio_telemetry_tracker_drone_gcs/main.py new file mode 100644 index 0000000..6ff7aaf --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/main.py @@ -0,0 +1,29 @@ +"""Main entry point for the RTT Drone GCS application.""" + +import sys + +from PyQt6.QtWidgets import QApplication + +from radio_telemetry_tracker_drone_gcs.comms.communication_bridge import CommunicationBridge +from radio_telemetry_tracker_drone_gcs.services.tile_db import init_db +from radio_telemetry_tracker_drone_gcs.window import MainWindow + + +def main() -> int: + """Start the RTT Drone GCS application.""" + # Initialize DB (tiles + POIs) + init_db() + + app = QApplication(sys.argv) + window = MainWindow() + + # Create bridging object + bridge = CommunicationBridge() + window.set_bridge(bridge) + + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/radio_telemetry_tracker_drone_gcs/services/__init__.py b/radio_telemetry_tracker_drone_gcs/services/__init__.py new file mode 100644 index 0000000..2203967 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/services/__init__.py @@ -0,0 +1 @@ +"""Service modules for database operations, tile management, and POI handling.""" diff --git a/radio_telemetry_tracker_drone_gcs/services/poi_db.py b/radio_telemetry_tracker_drone_gcs/services/poi_db.py new file mode 100644 index 0000000..285b644 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/services/poi_db.py @@ -0,0 +1,139 @@ +"""SQLite database operations for managing points of interest (POIs).""" + +from __future__ import annotations + +import logging +import sqlite3 +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from radio_telemetry_tracker_drone_gcs.utils.paths import get_db_path + +if TYPE_CHECKING: + from collections.abc import Generator + +DB_PATH = get_db_path() + +# Coordinate boundaries +MIN_LATITUDE = -90 +MAX_LATITUDE = 90 +MIN_LONGITUDE = -180 +MAX_LONGITUDE = 180 + +@contextmanager +def get_db_connection() -> Generator[sqlite3.Connection, None, None]: + """Get a database connection with optimized settings.""" + conn = None + try: + conn = sqlite3.connect(DB_PATH, timeout=20) + # Optimize connection + conn.execute("PRAGMA synchronous=NORMAL") # Faster than FULL, still safe + conn.execute("PRAGMA temp_store=MEMORY") + conn.execute("PRAGMA cache_size=-2000") # Use 2MB of cache + yield conn + except sqlite3.Error: + logging.exception("Database error") + raise + finally: + if conn: + conn.close() + + +def init_db() -> None: + """Initialize the POI database with optimized settings.""" + try: + with get_db_connection() as conn: + # Enable WAL mode for better concurrent access + conn.execute("PRAGMA journal_mode=WAL") + + # Drop existing table if it exists + conn.execute("DROP TABLE IF EXISTS pois") + + # Create table with correct schema + conn.execute(""" + CREATE TABLE IF NOT EXISTS pois ( + name TEXT PRIMARY KEY, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Add spatial index on coordinates + conn.execute("CREATE INDEX IF NOT EXISTS idx_pois_coords ON pois(latitude, longitude)") + + # Add trigger to update timestamp + conn.execute(""" + CREATE TRIGGER IF NOT EXISTS update_poi_timestamp + AFTER UPDATE ON pois + BEGIN + UPDATE pois SET updated_at = CURRENT_TIMESTAMP + WHERE name = NEW.name; + END; + """) + + conn.commit() + except sqlite3.Error: + logging.exception("Error initializing POI database") + raise + + +def list_pois_db() -> list[dict]: + """Retrieve all points of interest ordered by name.""" + try: + with get_db_connection() as conn: + cursor = conn.execute("SELECT name, latitude, longitude FROM pois ORDER BY name") + return [{"name": name, "coords": [lat, lng]} for name, lat, lng in cursor] + except sqlite3.Error: + logging.exception("Error listing POIs") + return [] + + +def add_poi_db(name: str, lat: float, lng: float) -> bool: + """Add or update a POI with validation.""" + if not (MIN_LATITUDE <= lat <= MAX_LATITUDE) or not (MIN_LONGITUDE <= lng <= MAX_LONGITUDE): + logging.error("Invalid coordinates: lat=%f, lng=%f", lat, lng) + return False + + try: + with get_db_connection() as conn: + conn.execute("INSERT OR REPLACE INTO pois (name, latitude, longitude) VALUES (?, ?, ?)", (name, lat, lng)) + conn.commit() + return True + except sqlite3.Error: + logging.exception("Error adding POI") + return False + + +def remove_poi_db(name: str) -> bool: + """Remove a POI and return success status.""" + try: + with get_db_connection() as conn: + cursor = conn.execute("DELETE FROM pois WHERE name = ?", (name,)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error: + logging.exception("Error removing POI") + return False + + +def rename_poi_db(old: str, new: str) -> bool: + """Rename a POI with validation and return success status.""" + if not old or not new: + return False + + try: + with get_db_connection() as conn: + # Check if new name already exists + cursor = conn.execute("SELECT 1 FROM pois WHERE name = ?", (new,)) + if cursor.fetchone() and old.lower() != new.lower(): + logging.error("POI with name '%s' already exists", new) + return False + + cursor = conn.execute("UPDATE pois SET name = ? WHERE name = ?", (new, old)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error: + logging.exception("Error renaming POI") + return False diff --git a/radio_telemetry_tracker_drone_gcs/services/poi_service.py b/radio_telemetry_tracker_drone_gcs/services/poi_service.py new file mode 100644 index 0000000..8c049bc --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/services/poi_service.py @@ -0,0 +1,71 @@ +"""poi_service.py: higher-level logic for POIs, calls poi_db for CRUD operations.""" + +import logging +from typing import Any + +from radio_telemetry_tracker_drone_gcs.services.poi_db import ( + add_poi_db, + init_db, + list_pois_db, + remove_poi_db, + rename_poi_db, +) + + +class PoiService: + """Manages POI retrieval, creation, removal, rename, etc.""" + + def __init__(self) -> None: + """Initialize the POI service by initializing the database.""" + init_db() + + def get_pois(self) -> list[dict[str, Any]]: + """Get all POIs from the database.""" + return list_pois_db() + + def add_poi(self, name: str, coords: list[float]) -> bool: + """Add a new POI to the database. + + Args: + name: Name of the POI + coords: List containing [latitude, longitude] + + Returns: + bool: True if POI was added successfully, False otherwise + """ + try: + return add_poi_db(name, coords[0], coords[1]) + except Exception: + logging.exception("Error adding POI") + return False + + def remove_poi(self, name: str) -> bool: + """Remove a POI from the database. + + Args: + name: Name of the POI to remove + + Returns: + bool: True if POI was removed successfully, False otherwise + """ + try: + return remove_poi_db(name) + except Exception: + logging.exception("Error removing POI") + return False + + def rename_poi(self, old_name: str, new_name: str) -> bool: + """Rename a POI in the database. + + Args: + old_name: Current name of the POI + new_name: New name for the POI + + Returns: + bool: True if POI was renamed successfully, False otherwise + """ + try: + return rename_poi_db(old_name, new_name) + except Exception: + logging.exception("Error renaming POI") + return False diff --git a/radio_telemetry_tracker_drone_gcs/services/simulator_core.py b/radio_telemetry_tracker_drone_gcs/services/simulator_core.py new file mode 100644 index 0000000..1c21aba --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/services/simulator_core.py @@ -0,0 +1,915 @@ +"""Simulator core module for the Radio Telemetry Tracker drone GCS.""" + +from __future__ import annotations + +import datetime as dt +import logging +import math +import random +import threading +import time +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Callable + +import numpy as np +import pyproj +from radio_telemetry_tracker_drone_comms_package import ( + ConfigRequestData, + ConfigResponseData, + DroneComms, + ErrorData, + GPSData, + LocEstData, + PingData, + RadioConfig, + StartRequestData, + StartResponseData, + StopRequestData, + StopResponseData, + SyncRequestData, + SyncResponseData, +) +from scipy.optimize import least_squares + +if TYPE_CHECKING: + from collections.abc import Iterable + +logger = logging.getLogger(__name__) + + +class DroneState(Enum): + """Represents the current state of the drone.""" + + IDLE = "IDLE" + TAKEOFF = "TAKEOFF" + FLYING = "FLYING" + RETURNING = "RETURNING" + LANDING = "LANDING" + + +@dataclass +class WayPoint: + """Represents a waypoint in UTM coordinates.""" + + easting: float + northing: float + altitude: float + + +class GpsDataGenerator: + """Generates GPS data for the simulator.""" + + def __init__(self) -> None: + """Initialize the GPS data generator with starting parameters.""" + # UTM zone parameters + self.zone: str = "11S" + self.hemisphere: str = "north" + + # Create UTM and WGS84 transformers + self.utm_proj = pyproj.Proj(proj="utm", zone=11, ellps="WGS84", hemisphere="north") + self.wgs84_proj = pyproj.Proj("epsg:4326") + self.transformer = pyproj.Transformer.from_proj(self.utm_proj, self.wgs84_proj, always_xy=True) + + # Starting position (UTM coordinates) + self.start_point = WayPoint(489276.681, 3611282.577, 2.0) + self.end_point = WayPoint(489504.058, 3611478.990, 2.0) + + # Flight parameters + self.target_altitude: float = 30.0 # meters + self.vertical_speed: float = 2.0 # m/s + self.horizontal_speed: float = 5.0 # m/s + self.waypoint_radius: float = 5.0 # meters + self.update_rate: int = 5 # Hz + + # State variables + self.current_state: DroneState = DroneState.IDLE + self.current_position = WayPoint(self.start_point.easting, self.start_point.northing, self.start_point.altitude) + self.current_heading: float = 0.0 + self.waypoints: list[WayPoint] = [] + self.current_waypoint_idx: int = 0 + self.packet_id: int = 0 + + # GPS noise parameters + self.position_noise_std: float = 0.3 # meters (typical GPS accuracy) + self.altitude_noise_std: float = 0.5 # meters (altitude is typically less accurate) + + # Thread control + self._running: bool = False + self._update_thread: threading.Thread | None = None + self._last_update: float = time.time() + self._rng = random.SystemRandom() + + # Generate lawnmower pattern waypoints + self._generate_lawnmower_pattern() + + def _generate_lawnmower_pattern(self) -> None: + """Generate waypoints for a lawnmower pattern.""" + # Calculate box dimensions + height = self.end_point.northing - self.start_point.northing + + # Use 30m spacing between passes + spacing = 20.0 # meters + num_passes = max(2, int(height / spacing) + 1) + + # Generate waypoints + self.waypoints = [] + + # Add takeoff point + self.waypoints.append(WayPoint(self.start_point.easting, self.start_point.northing, self.target_altitude)) + + # Generate lawnmower pattern + for i in range(num_passes): + northing = self.start_point.northing + i * spacing + + # Add points for each pass + if i % 2 == 0: + # Left to right + self.waypoints.append(WayPoint(self.start_point.easting, northing, self.target_altitude)) + self.waypoints.append(WayPoint(self.end_point.easting, northing, self.target_altitude)) + else: + # Right to left + self.waypoints.append(WayPoint(self.end_point.easting, northing, self.target_altitude)) + self.waypoints.append(WayPoint(self.start_point.easting, northing, self.target_altitude)) + + def _add_gps_noise(self, easting: float, northing: float, altitude: float) -> tuple[float, float, float]: + """Add realistic GPS noise to position.""" + noisy_easting = easting + self._rng.gauss(0, self.position_noise_std) + noisy_northing = northing + self._rng.gauss(0, self.position_noise_std) + noisy_altitude = altitude + self._rng.gauss(0, self.altitude_noise_std) + return noisy_easting, noisy_northing, noisy_altitude + + def _calculate_heading(self, current: WayPoint, target: WayPoint) -> float: + """Calculate heading angle in degrees from current position to target. + + North = 0°, East = 90°, South = 180°, West = 270°. + """ + dx = target.easting - current.easting + dy = target.northing - current.northing + heading = 90 - math.degrees(math.atan2(dy, dx)) # Convert from math angle to compass heading + return heading % 360 + + def _move_towards_waypoint(self, current: WayPoint, target: WayPoint, dt: float) -> WayPoint: + """Move from current position towards target waypoint.""" + dx = target.easting - current.easting + dy = target.northing - current.northing + dz = target.altitude - current.altitude + + # Calculate distances + horizontal_dist = math.sqrt(dx * dx + dy * dy) + + # Calculate movement distances for this timestep + max_horiz_dist = self.horizontal_speed * dt + max_vert_dist = self.vertical_speed * dt + + # Calculate scaling factors + horiz_scale = min(1.0, max_horiz_dist / horizontal_dist) if horizontal_dist > 0 else 0 + vert_scale = min(1.0, max_vert_dist / abs(dz)) if dz != 0 else 0 + + # Calculate new position + new_easting = current.easting + dx * horiz_scale + new_northing = current.northing + dy * horiz_scale + new_altitude = current.altitude + dz * vert_scale + + return WayPoint(new_easting, new_northing, new_altitude) + + def _is_at_waypoint(self, current: WayPoint, target: WayPoint) -> bool: + """Check if we've reached the target waypoint.""" + dx = target.easting - current.easting + dy = target.northing - current.northing + dz = target.altitude - current.altitude + + horizontal_dist = math.sqrt(dx * dx + dy * dy) + return horizontal_dist < self.waypoint_radius and abs(dz) < 1.0 + + def _handle_idle_state(self, dt: float) -> None: + """Handle IDLE state.""" + + def _handle_takeoff_state(self, dt: float) -> None: + """Handle TAKEOFF state.""" + target = self.waypoints[0] + self.current_position = self._move_towards_waypoint(self.current_position, target, dt) + self.current_heading = self._calculate_heading(self.current_position, target) + + if self._is_at_waypoint(self.current_position, target): + self.current_state = DroneState.FLYING + self.current_waypoint_idx = 1 + + def _handle_flying_state(self, dt: float) -> bool: + """Handle FLYING state. Returns True if should continue, False if should return None.""" + if self.current_waypoint_idx >= len(self.waypoints): + self.current_state = DroneState.RETURNING + return False + + target = self.waypoints[self.current_waypoint_idx] + self.current_position = self._move_towards_waypoint(self.current_position, target, dt) + self.current_heading = self._calculate_heading(self.current_position, target) + + if self._is_at_waypoint(self.current_position, target): + self.current_waypoint_idx += 1 + return True + + def _handle_returning_state(self, dt: float) -> None: + """Handle RETURNING state.""" + target = WayPoint(self.start_point.easting, self.start_point.northing, self.target_altitude) + self.current_position = self._move_towards_waypoint(self.current_position, target, dt) + self.current_heading = self._calculate_heading(self.current_position, target) + + if self._is_at_waypoint(self.current_position, target): + self.current_state = DroneState.LANDING + + def _handle_landing_state(self, dt: float) -> None: + """Handle LANDING state.""" + target = self.start_point + self.current_position = self._move_towards_waypoint(self.current_position, target, dt) + + if self._is_at_waypoint(self.current_position, target): + self.current_state = DroneState.IDLE + + def _update_loop(self) -> None: + """Main update loop for GPS position.""" + try: + while self._running: + current_time = time.time() + dt = current_time - self._last_update + self._last_update = current_time + + # Update position based on current state + if self.current_state == DroneState.TAKEOFF: + self._handle_takeoff_state(dt) + elif self.current_state == DroneState.FLYING: + if self.current_waypoint_idx >= len(self.waypoints): + self.current_state = DroneState.RETURNING + else: + self._handle_flying_state(dt) + elif self.current_state == DroneState.RETURNING: + self._handle_returning_state(dt) + elif self.current_state == DroneState.LANDING: + self._handle_landing_state(dt) + + # Sleep to maintain update rate + time.sleep(1.0 / self.update_rate) + except Exception: + logger.exception("Error in GPS update loop") + + def start(self) -> None: + """Start the GPS generator thread.""" + if self._update_thread is not None: + return + self._running = True + self._update_thread = threading.Thread(target=self._update_loop, daemon=True) + self._update_thread.start() + + def stop(self) -> None: + """Stop the GPS generator thread.""" + self._running = False + if self._update_thread: + self._update_thread.join(timeout=2.0) + if self._update_thread.is_alive(): + logger.warning("GPS generator thread did not stop cleanly") + self._update_thread = None + + def get_current_position(self) -> GPSData: + """Get the current position with GPS noise.""" + # Add noise and generate GPS data + noisy_easting, noisy_northing, noisy_altitude = self._add_gps_noise( + self.current_position.easting, + self.current_position.northing, + self.current_position.altitude, + ) + + # Create GPS data packet + self.packet_id += 1 + return GPSData( + easting=noisy_easting, + northing=noisy_northing, + altitude=noisy_altitude, + heading=self.current_heading, + epsg_code=32611, # EPSG code for UTM zone 11N + ) + + def start_flight(self) -> None: + """Start the flight sequence.""" + if self.current_state == DroneState.IDLE: + self.current_state = DroneState.TAKEOFF + + def return_to_home(self) -> None: + """Command the drone to return to home.""" + if self.current_state in [DroneState.FLYING, DroneState.TAKEOFF]: + self.current_state = DroneState.RETURNING + + +class SimulatorCore: + """Controls the simulator instance and manages communication in a separate thread.""" + + def __init__(self, radio_config: RadioConfig) -> None: + """Initialize simulator with radio configuration.""" + self._comms = DroneComms(radio_config=radio_config, ack_timeout=1, max_retries=1) + self._gps_generator = GpsDataGenerator() + self._ping_finder: SimulatedPingFinder | None = None + self._location_estimator: LocationEstimator | None = None + self._running = True # Set running to True initially + self._pending_actions: dict[int, tuple[str, dict]] = {} + self._rng = random.SystemRandom() + self._gps_thread: threading.Thread | None = None + + # Register handlers and start communications + self._register_handlers() + self._start_drone_comms() + + def _register_handlers(self) -> None: + """Register handlers for various drone commands.""" + self._comms.register_sync_request_handler(self._handle_sync_request) + self._comms.register_start_request_handler(self._handle_start_request) + self._comms.register_stop_request_handler(self._handle_stop_request) + self._comms.register_config_request_handler(self._handle_config_request) + + self._comms.on_ack_success = self._handle_ack_success + self._comms.on_ack_failure = self._handle_ack_failure + + def _start_drone_comms(self) -> None: + """Start the drone communications and GPS data thread.""" + self._comms.start() + self._gps_generator.start() # Start GPS generator + self._gps_thread = threading.Thread(target=self._gps_data_loop, daemon=True) + self._gps_thread.start() + + def _gps_data_loop(self) -> None: + """Main loop for sending GPS data.""" + logger.info("Starting GPS data loop") + try: + while self._running: + # Get current position and send it + gps_data = self._gps_generator.get_current_position() + logger.info( + "Sending GPS data: easting=%.2f, northing=%.2f, altitude=%.2f, heading=%.2f", + gps_data.easting, + gps_data.northing, + gps_data.altitude, + gps_data.heading, + ) + packet_id, _, _ = self._comms.send_gps_data(gps_data) + logger.info("Sent GPS data with packet_id %d", packet_id) + + # Sleep for 1 second between updates + time.sleep(1.0) + except Exception: + logger.exception("Error in GPS data loop") + finally: + logger.info("GPS data loop stopped") + + def _handle_ack_success(self, packet_id: int) -> None: + """Handle successful acknowledgment.""" + logger.info("Received successful ACK for packet %d", packet_id) + if packet_id not in self._pending_actions: + logger.warning("No pending action found for packet %d", packet_id) + return + + action_type, action_data = self._pending_actions.pop(packet_id) + logger.info("Executing %s action for packet %d", action_type, packet_id) + + try: + if action_type == "sync": + self._execute_sync_action() + elif action_type == "start": + self._execute_start_action() + elif action_type == "stop": + self._execute_stop_action() + elif action_type == "config": + self._execute_config_action(action_data) + logger.info("Successfully executed %s action", action_type) + except Exception: + msg = f"Failed to execute {action_type} action after acknowledgement" + logger.exception(msg) + self._comms.send_error(ErrorData()) + + def _handle_ack_failure(self, packet_id: int) -> None: + """Handle failed acknowledgment.""" + logger.warning("Received failed ACK for packet %d", packet_id) + if packet_id in self._pending_actions: + action_type, _ = self._pending_actions.pop(packet_id) + logger.error("Failed to get acknowledgment for %s action (packet %d)", action_type, packet_id) + self._comms.send_error(ErrorData()) + + def _execute_sync_action(self) -> None: + """Execute sync action after acknowledgment.""" + logger.info("Executing sync action") + if self._ping_finder is not None: + self._ping_finder.stop() + self._ping_finder = None + + def _get_current_location(self, _: dt.datetime) -> tuple[float, float, float]: + """Get current location for the location estimator.""" + pos = self._gps_generator.current_position + logger.info("Location estimator getting position: (%.2f, %.2f, %.2f)", pos.easting, pos.northing, pos.altitude) + return pos.easting, pos.northing, pos.altitude + + def _on_ping_detected(self, now: dt.datetime, amplitude: float, frequency: int) -> None: + """Handle ping detection.""" + # Get current position + pos = self._gps_generator.current_position + logger.info( + "Ping detected - Freq: %d Hz, Amplitude: %.2f dB, Position: (%.2f, %.2f, %.2f)", + frequency, + amplitude, + pos.easting, + pos.northing, + pos.altitude, + ) + + # Send ping data + ping_data = PingData( + frequency=frequency, + amplitude=amplitude, + easting=pos.easting, + northing=pos.northing, + altitude=pos.altitude, + epsg_code=32611, # UTM zone 11N + ) + self._comms.send_ping_data(ping_data) + logger.debug("Sent ping data to GCS") + + # Add ping to location estimator and get estimate + if self._location_estimator: + self._location_estimator.add_ping(now, amplitude, frequency) + try: + estimate = self._location_estimator.do_estimate(frequency) + if estimate is not None: + logger.info("Location estimate for %d Hz: (%.2f, %.2f)", frequency, estimate[0], estimate[1]) + # Send location estimate + loc_est_data = LocEstData( + frequency=frequency, + easting=estimate[0], + northing=estimate[1], + epsg_code=32611, # UTM zone 11N + ) + self._comms.send_loc_est_data(loc_est_data) + logger.debug("Sent location estimate to GCS") + except ValueError as e: + logger.warning("Failed to estimate location: %s", str(e)) + else: + logger.warning("Location estimator not initialized") + + def _execute_start_action(self) -> None: + """Execute start action after acknowledgment.""" + if self._ping_finder is None: + msg = "Cannot start ping finder: not configured" + logger.error(msg) + raise RuntimeError(msg) + + # Create location estimator if needed + if self._location_estimator is None: + self._location_estimator = LocationEstimator(self._get_current_location) + + # Register callback and start + self._ping_finder.register_callback(self._on_ping_detected) + self._ping_finder.start() + self._gps_generator.start_flight() + + def _execute_stop_action(self) -> None: + """Execute stop action after acknowledgment.""" + if self._ping_finder is None: + msg = "Cannot stop ping finder: not configured" + logger.error(msg) + raise RuntimeError(msg) + self._ping_finder.stop() + self._gps_generator.return_to_home() + self._location_estimator = None # Reset location estimator + + def _execute_config_action(self, config_data: dict) -> None: + """Execute config action after acknowledgment.""" + # Create new ping finder if needed + if self._ping_finder is None: + self._ping_finder = SimulatedPingFinder(self._gps_generator) + + # Add simulated transmitters based on target frequencies + for freq in config_data["target_frequencies"]: + # Place transmitters randomly in the search area + x = self._rng.uniform(self._gps_generator.start_point.easting, self._gps_generator.end_point.easting) + y = self._rng.uniform(self._gps_generator.start_point.northing, self._gps_generator.end_point.northing) + z = 2.0 # Ground level + self._ping_finder.add_transmitter(freq, (x, y, z)) + + def _handle_sync_request(self, _: SyncRequestData) -> None: + """Handle sync request from GCS.""" + logger.info("Received sync request from GCS") + packet_id, _, _ = self._comms.send_sync_response(SyncResponseData(success=True)) + logger.info("Sent sync response with packet_id %d", packet_id) + self._pending_actions[packet_id] = ("sync", {}) + + def _handle_start_request(self, _: StartRequestData) -> None: + """Handle start request from GCS.""" + logger.info("Received start request from GCS") + success = self._ping_finder is not None + packet_id, _, _ = self._comms.send_start_response(StartResponseData(success=success)) + logger.info("Sent start response with packet_id %d (success=%s)", packet_id, success) + if success: + self._pending_actions[packet_id] = ("start", {}) + else: + logger.warning("Start request failed: ping finder not initialized") + + def _handle_stop_request(self, _: StopRequestData) -> None: + """Handle stop request from GCS.""" + logger.info("Received stop request from GCS") + success = self._ping_finder is not None + packet_id, _, _ = self._comms.send_stop_response(StopResponseData(success=success)) + logger.info("Sent stop response with packet_id %d (success=%s)", packet_id, success) + if success: + self._pending_actions[packet_id] = ("stop", {}) + else: + logger.warning("Stop request failed: ping finder not initialized") + + def _handle_config_request(self, data: ConfigRequestData) -> None: + """Handle configuration request from GCS.""" + logger.info("Received config request from GCS") + try: + config_dict = { + "gain": data.gain, + "sampling_rate": data.sampling_rate, + "center_frequency": data.center_frequency, + "run_num": data.run_num, + "enable_test_data": data.enable_test_data, + "ping_width_ms": data.ping_width_ms, + "ping_min_snr": data.ping_min_snr, + "ping_max_len_mult": data.ping_max_len_mult, + "ping_min_len_mult": data.ping_min_len_mult, + "target_frequencies": list(data.target_frequencies), + } + logger.info("Config request data: %s", config_dict) + + packet_id, _, _ = self._comms.send_config_response(ConfigResponseData(success=True)) + logger.info("Sent config response with packet_id %d", packet_id) + self._pending_actions[packet_id] = ("config", config_dict) + + except Exception: + logger.exception("Failed to prepare config") + self._comms.send_error(ErrorData()) + + def start(self) -> None: + """Start the simulator.""" + self._running = True + if not self._gps_thread or not self._gps_thread.is_alive(): + self._gps_thread = threading.Thread(target=self._gps_data_loop, daemon=True) + self._gps_thread.start() + + def stop(self) -> None: + """Stop the simulator.""" + self._running = False + if self._ping_finder: + self._ping_finder.stop() + self._gps_generator.stop() + if self._gps_thread: + self._gps_thread.join(timeout=2.0) + if self._gps_thread.is_alive(): + logger.warning("GPS thread did not stop cleanly") + + +@dataclass +class Ping: + """Ping dataclass.""" + + x: float + y: float + z: float + power: float + freq: int + time: dt.datetime + + def to_numpy(self) -> np.ndarray: + """Converts the Ping to a numpy array. + + Returns: + np.ndarray: Numpy array for processing + """ + return np.array([self.x, self.y, self.z, self.power]) + + +class LocationEstimator: + """Location Estimator. + + All coordinate systems assumed have units in meters, using ENU order of axis + """ + + MIN_PINGS_FOR_ESTIMATE = 4 + + def __init__(self, location_lookup: Callable[[dt.datetime], tuple[float, float, float]]) -> None: + """Initialize location estimator with a location lookup function. + + Args: + location_lookup: Function that returns (x, y, z) coordinates for a given timestamp + """ + self.__loc_fn = location_lookup + + self.__pings: dict[int, list[Ping]] = {} + + self.__estimate: dict[int, np.ndarray] = {} + + def add_ping(self, now: dt.datetime, amplitude: float, frequency: int) -> None: + """Adds a ping. + + Ping location is set via the location_lookup callback + + Args: + now (dt.datetime): timestamp of ping + amplitude (float): Amplitude of ping + frequency (int): Ping frequency + """ + x, y, z = self.__loc_fn(now) + new_ping = Ping(x, y, z, amplitude, frequency, now) + if frequency not in self.__pings: + self.__pings[frequency] = [new_ping] + else: + self.__pings[frequency].append(new_ping) + + def do_estimate( + self, + frequency: int, + *, + xy_bounds: tuple[float, float, float, float] | None = None, + ) -> tuple[float, float, float] | None: + """Performs the estimate. + + Args: + frequency (int): Frequency to estimate on + xy_bounds (Tuple[float, float, float, float]): Optional XY bounds as + [xmin xmax ymin ymax] to enforce on the estimate. Defaults to UTM coordinate min/max. + + Raises: + KeyError: Unknown frequency + + Returns: + Optional[Tuple[float, float, float]]: If estimate is valid, the XYZ coordinates, + otherwise, None + """ + if frequency not in self.__pings: + msg = "Unknown frequency" + raise KeyError(msg) + + if len(self.__pings[frequency]) < self.MIN_PINGS_FOR_ESTIMATE: + return None + + if not xy_bounds: + xy_bounds = (167000, 833000, 0, 10000000) + + pings = np.array([ping.to_numpy() for ping in self.__pings[frequency]]) + + # Get the actual transmitter position (last ping position) + actual_x = pings[-1, 0] + actual_y = pings[-1, 1] + + x_tx_0 = np.mean(pings[:, 0]) + y_tx_0 = np.mean(pings[:, 1]) + p_tx_0 = np.max(pings[:, 3]) + + n_0 = 2 + + params = self.__estimate[frequency] if frequency in self.__estimate else np.array([x_tx_0, y_tx_0, p_tx_0, n_0]) + res_x = least_squares( + fun=self.__residuals, + x0=params, + bounds=([xy_bounds[0], xy_bounds[2], -np.inf, 2], [xy_bounds[1], xy_bounds[3], np.inf, 2.1]), + args=(pings,), + ) + + if res_x.success: + # Use the optimized parameters from res_x.x + self.__estimate[frequency] = res_x.x + retval = (res_x.x[0], res_x.x[1], 0) + + # Calculate distance between estimate and actual position + distance = np.sqrt((res_x.x[0] - actual_x) ** 2 + (res_x.x[1] - actual_y) ** 2) + logging.info( + "Location estimate for %d Hz: (%.2f, %.2f), Distance from actual: %.2f m", + frequency, + res_x.x[0], + res_x.x[1], + distance, + ) + else: + retval = None + + return retval + + def get_frequencies(self) -> Iterable[int]: + """Gets the current frequencies. + + Returns: + Iterable[int]: Iterable of frequencies + """ + return self.__pings.keys() + + def __residuals(self, params: np.ndarray, data: np.ndarray) -> np.ndarray: + # Params is expected to be shape(4,) + # Data is expected to be shape(n, 4) + estimated_transmitter_x = params[0] + estimated_transmitter_y = params[1] + estimated_transmitter_location = np.array([estimated_transmitter_x, estimated_transmitter_y, 0]) + + estimated_transmitter_power = params[2] + estimated_model_order = params[3] + + received_power = data[:, 3] + received_locations = data[:, 0:3] + + np.zeros(len(received_power)) + distances = np.linalg.norm(received_locations - estimated_transmitter_location, axis=1) + return received_power - self.__distance_to_receive_power( + distances, + estimated_transmitter_power, + estimated_model_order, + ) + + def __distance_to_receive_power(self, distance: np.ndarray, k: float, order: float) -> np.ndarray: + return k - 10 * order * np.log10(distance) + + +class SimulatedPingFinder: + """Simulates ping detection from radio transmitters.""" + + SNR_THRESHOLD = -60 # dB + MIN_DISTANCE = 1.0 # meters, avoid log(0) + NOISE_STD = 2.0 # dB, standard deviation of noise + PING_INTERVAL = 1.0 # seconds + PING_JITTER = 0.1 # seconds + MAX_DETECTION_RANGE = 500.0 # meters, maximum range where detection is possible + BASE_DETECTION_PROB = 0.6 # base probability of detection at optimal range + + def __init__(self, gps_generator: GpsDataGenerator) -> None: + """Initialize the simulated ping finder.""" + self._gps_generator = gps_generator + self._callback: Callable[[dt.datetime, float, int], None] | None = None + self._running: bool = False + self._thread: threading.Thread | None = None + self._rng = random.SystemRandom() + + # Simulated transmitter configurations + self._transmitters: dict[int, tuple[float, float, float, float, float]] = {} # freq -> (x, y, z, power, order) + + # Ping timing parameters + self._next_ping_times: dict[int, float] = {} # freq -> next ping time + + def _calculate_next_ping_time(self, current_time: float) -> float: + """Calculate the next ping time with small jitter. + + Args: + current_time: Current time in seconds + + Returns: + float: Next ping time in seconds + """ + jitter = self._rng.uniform(-self.PING_JITTER, self.PING_JITTER) + return current_time + self.PING_INTERVAL + jitter + + def _calculate_detection_probability(self, distance: float) -> float: + """Calculate probability of detection based on distance. + + Uses a sigmoid-like function that gives higher probability when closer + to the transmitter and drops off as distance increases. + + Args: + distance: Distance to transmitter in meters + + Returns: + float: Probability of detection between 0 and 1 + """ + if distance > self.MAX_DETECTION_RANGE: + return 0.0 + + # Scale distance to be between 0 and 1 + scaled_dist = distance / self.MAX_DETECTION_RANGE + # Use sigmoid-like function to calculate probability + prob = self.BASE_DETECTION_PROB * (1 - scaled_dist**2) + return max(0.0, min(1.0, prob)) + + def _distance_to_receive_power(self, distance: float, k: float, order: float) -> float: + """Calculate received power based on distance. + + Args: + distance: Distance in meters + k: Transmitter power in dB + order: Path loss order + + Returns: + float: Received power in dB + """ + return k - 10 * order * np.log10(max(distance, self.MIN_DISTANCE)) + + def register_callback(self, callback: Callable[[dt.datetime, float, int], None]) -> None: + """Register callback for ping detections. + + Args: + callback: Function to call when ping is detected with (timestamp, power, frequency) + """ + self._callback = callback + + def add_transmitter( + self, + frequency: int, + position: tuple[float, float, float], + power: float = 100.0, + order: float = 2.0, + ) -> None: + """Add a simulated transmitter. + + Args: + frequency: Transmitter frequency in Hz + position: (x, y, z) coordinates in UTM + power: Transmitter power in dB + order: Path loss order (typically 2-4) + """ + self._transmitters[frequency] = (*position, power, order) + # Initialize next ping time for this frequency with random offset + self._next_ping_times[frequency] = time.time() + self._rng.uniform(0, self.PING_INTERVAL) + + def _should_ping(self, frequency: int) -> bool: + """Determine if a ping should occur based on timing. + + Args: + frequency: Transmitter frequency + + Returns: + bool: True if should ping, False otherwise + """ + current_time = time.time() + next_ping_time = self._next_ping_times.get(frequency, current_time) + + if current_time >= next_ping_time: + # Calculate next ping time + self._next_ping_times[frequency] = self._calculate_next_ping_time(current_time) + return True + return False + + def _simulate_ping(self, frequency: int, drone_pos: WayPoint) -> tuple[float, bool]: + """Simulate ping detection with realistic signal propagation. + + Args: + frequency: Transmitter frequency + drone_pos: Current drone position + + Returns: + tuple[float, bool]: (received power in dB, whether ping was detected) + """ + if frequency not in self._transmitters: + return -float("inf"), False + + # Check if it's time for a ping + if not self._should_ping(frequency): + return -float("inf"), False + + tx_x, tx_y, tx_z, power, order = self._transmitters[frequency] + + # Calculate 3D distance to transmitter + dx = tx_x - drone_pos.easting + dy = tx_y - drone_pos.northing + dz = tx_z - drone_pos.altitude + distance = np.sqrt(dx * dx + dy * dy + dz * dz) + + # Calculate detection probability based on distance + detection_prob = self._calculate_detection_probability(distance) + + # Random chance to miss the ping based on distance-based probability + if self._rng.random() > detection_prob: + return -float("inf"), False + + # Calculate received power with some noise + received_power = self._distance_to_receive_power(distance, power, order) + received_power += self._rng.gauss(0, self.NOISE_STD) + + # Determine if ping is detected (based on SNR threshold) + is_detected = received_power > self.SNR_THRESHOLD + + return received_power, is_detected + + def _run(self) -> None: + """Main simulation loop.""" + try: + while self._running: + now = dt.datetime.now(dt.timezone.utc) + pos = self._gps_generator.current_position + + # Simulate pings for each transmitter + for freq in self._transmitters: + power, detected = self._simulate_ping(freq, pos) + if detected and self._callback: + self._callback(now, power, freq) + + # Sleep for a short time to check for pings + time.sleep(0.1) # Check more frequently for better timing accuracy + except Exception: + logger.exception("Error in ping simulation loop") + + def start(self) -> None: + """Start the ping finder simulation.""" + if not self._running: + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the ping finder simulation.""" + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + if self._thread.is_alive(): + logger.warning("Ping finder thread did not stop cleanly") + self._thread = None diff --git a/radio_telemetry_tracker_drone_gcs/services/simulator_service.py b/radio_telemetry_tracker_drone_gcs/services/simulator_service.py new file mode 100644 index 0000000..311190d --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/services/simulator_service.py @@ -0,0 +1,80 @@ +"""Simulator service for testing and development.""" + +from __future__ import annotations + +import logging +import multiprocessing +from typing import TYPE_CHECKING + +from radio_telemetry_tracker_drone_gcs.services.simulator_core import SimulatorCore + +if TYPE_CHECKING: + from radio_telemetry_tracker_drone_comms_package import RadioConfig + +logger = logging.getLogger(__name__) + + +def run_simulator(radio_config: RadioConfig) -> None: + """Run the simulator in a separate process.""" + try: + logger.info("Starting simulator core...") + simulator = SimulatorCore(radio_config) + simulator.start() + logger.info("Simulator core started successfully") + # Keep the process alive + while True: + multiprocessing.Event().wait(1.0) + except Exception: + logger.exception("Error in simulator process") + finally: + if "simulator" in locals(): + try: + simulator.stop() + logger.info("Simulator core stopped") + except Exception: + logger.exception("Error stopping simulator in process") + + +class SimulatorService: + """Controls the simulator instance and manages communication in a separate process.""" + + def __init__(self, radio_config: RadioConfig) -> None: + """Initialize simulator service.""" + self._radio_config = radio_config + self._process: multiprocessing.Process | None = None + + def start(self) -> None: + """Start the simulator in a separate process.""" + if self._process is not None: + msg = "Simulator process already running" + raise RuntimeError(msg) + + try: + logger.info("Launching simulator process...") + self._process = multiprocessing.Process( + target=run_simulator, + args=(self._radio_config,), + daemon=True, + ) + self._process.start() + logger.info("Simulator process started successfully") + except Exception: + logger.exception("Failed to start simulator process") + self.stop() + raise + + def stop(self) -> None: + """Stop the simulator and clean up resources.""" + if self._process: + try: + self._process.terminate() + self._process.join(timeout=2.0) + if self._process.is_alive(): + logger.warning("Simulator process did not stop cleanly, killing...") + self._process.kill() + self._process.join(timeout=1.0) + except Exception: + logger.exception("Error stopping simulator process") + + self._process = None + logger.info("Simulator stopped") diff --git a/radio_telemetry_tracker_drone_gcs/services/tile_db.py b/radio_telemetry_tracker_drone_gcs/services/tile_db.py new file mode 100644 index 0000000..9919073 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/services/tile_db.py @@ -0,0 +1,180 @@ +"""tile_db.py: direct SQLite code for tile caching (read/write).""" + +from __future__ import annotations + +import logging +import sqlite3 +from contextlib import contextmanager +from queue import Empty, Full, Queue +from threading import Lock +from typing import TYPE_CHECKING + +from radio_telemetry_tracker_drone_gcs.utils.paths import ensure_app_dir, get_db_path + +if TYPE_CHECKING: + from collections.abc import Generator + +ensure_app_dir() # Ensure app directory exists +DB_PATH = get_db_path() + +# Connection pool +MAX_CONNECTIONS = 5 +_connection_pool: Queue[sqlite3.Connection] = Queue(maxsize=MAX_CONNECTIONS) +_pool_lock = Lock() + + +def _create_connection() -> sqlite3.Connection: + """Create a new optimized database connection.""" + conn = sqlite3.connect(DB_PATH, timeout=20, check_same_thread=False) + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA temp_store=MEMORY") + conn.execute("PRAGMA cache_size=-2000") + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def _get_connection() -> sqlite3.Connection: + """Get a connection from the pool or create a new one.""" + try: + return _connection_pool.get_nowait() + except Empty: + return _create_connection() + + +def _return_connection(conn: sqlite3.Connection) -> None: + """Return a connection to the pool or close it if pool is full.""" + try: + _connection_pool.put_nowait(conn) + except Full: + conn.close() + + +@contextmanager +def get_db_connection() -> Generator[sqlite3.Connection, None, None]: + """Get a database connection from the pool.""" + conn = None + try: + conn = _get_connection() + # Set optimized connection settings + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA cache_size=-2000") + yield conn + except sqlite3.Error: + logging.exception("Database error") + raise + finally: + if conn: + _return_connection(conn) + + +def get_tile_db(z: int, x: int, y: int, source: str) -> bytes | None: + """Retrieve a tile from DB if cached.""" + try: + with get_db_connection() as conn: + cursor = conn.execute( + "SELECT data FROM tiles WHERE z=? AND x=? AND y=? AND source=?", + (z, x, y, source), + ) + row = cursor.fetchone() + return row[0] if row else None + except sqlite3.Error: + logging.exception("Error retrieving tile") + return None + + +def store_tile_db(z: int, x: int, y: int, source: str, data: bytes) -> bool: + """Store or update tile in DB. Returns success status.""" + try: + with get_db_connection() as conn: + conn.execute( + "INSERT OR REPLACE INTO tiles (z, x, y, source, data) VALUES (?, ?, ?, ?, ?)", + (z, x, y, source, data), + ) + conn.commit() + return True + except sqlite3.Error: + logging.exception("Error storing tile") + return False + + +def clear_tile_cache_db() -> int: + """Delete all cached tiles. Return number of rows removed.""" + try: + with get_db_connection() as conn: + cursor = conn.execute("DELETE FROM tiles") + conn.commit() + return cursor.rowcount + except sqlite3.Error: + logging.exception("Error clearing tile cache") + return 0 + + +def get_tile_info_db() -> dict: + """Return tile count and size in MB.""" + try: + with get_db_connection() as conn: + cursor = conn.execute("SELECT COUNT(*), COALESCE(SUM(LENGTH(data)), 0) FROM tiles") + count, size = cursor.fetchone() or (0, 0) + return { + "total_tiles": count, + "total_size_mb": round(size / (1024 * 1024), 2), + } + except sqlite3.Error: + logging.exception("Error getting tile info") + return {"total_tiles": 0, "total_size_mb": 0.0} + + +def init_db() -> None: + """Initialize the tile DB (and POI table) if not exists.""" + try: + with get_db_connection() as conn: + # Enable WAL mode for better concurrent access + conn.execute("PRAGMA journal_mode=WAL") + + # Create tables + conn.execute(""" + CREATE TABLE IF NOT EXISTS tiles ( + z INTEGER, + x INTEGER, + y INTEGER, + source TEXT, + data BLOB, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (z, x, y, source) + ) + """) + + # Add index on timestamp for cleanup operations + conn.execute("CREATE INDEX IF NOT EXISTS idx_tiles_timestamp ON tiles(timestamp)") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS pois ( + name TEXT PRIMARY KEY, + latitude REAL, + longitude REAL + ) + """) + + # Add spatial index on POI coordinates + conn.execute("CREATE INDEX IF NOT EXISTS idx_pois_coords ON pois(latitude, longitude)") + + # Add periodic cleanup trigger + conn.execute(""" + CREATE TRIGGER IF NOT EXISTS cleanup_old_tiles + AFTER INSERT ON tiles + BEGIN + DELETE FROM tiles + WHERE timestamp < datetime('now', '-30 days') + AND rowid NOT IN ( + SELECT rowid FROM tiles + ORDER BY timestamp DESC + LIMIT 10000 + ); + END; + """) + + conn.commit() + except sqlite3.Error: + logging.exception("Error initializing database") + raise diff --git a/radio_telemetry_tracker_drone_gcs/services/tile_service.py b/radio_telemetry_tracker_drone_gcs/services/tile_service.py new file mode 100644 index 0000000..91be2d4 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/services/tile_service.py @@ -0,0 +1,108 @@ +"""tile_service.py: orchestrates fetching tiles from internet or DB, plus offline logic.""" + +from __future__ import annotations + +import logging +from http import HTTPStatus + +import requests + +from radio_telemetry_tracker_drone_gcs.services.poi_db import init_db # Reuse same DB if needed +from radio_telemetry_tracker_drone_gcs.services.tile_db import ( + clear_tile_cache_db, + get_tile_db, + get_tile_info_db, + store_tile_db, +) + +SATELLITE_ATTRIBUTION = ( + "© Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, " + "Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community" +) + +# Hardcode map sources for now, or load from config +MAP_SOURCES = { + "osm": { + "id": "osm", + "name": "OpenStreetMap", + "url_template": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "attribution": "© OpenStreetMap contributors", + "headers": { + "User-Agent": "RTT-Drone-GCS/1.0", + "Accept": "image/png", + }, + }, + "satellite": { + "id": "satellite", + "name": "Satellite", + "url_template": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "attribution": SATELLITE_ATTRIBUTION, + "headers": { + "User-Agent": "RTT-Drone-GCS/1.0", + "Accept": "image/png", + }, + }, +} + + +class TileService: + """Handles tile caching logic, offline checks, tile fetching from net.""" + + def __init__(self) -> None: + """Initialize the tile service by ensuring the database is ready.""" + init_db() # ensure DB is ready + + def get_tile_info(self) -> dict: + """Get tile info from the database.""" + return get_tile_info_db() + + def clear_tile_cache(self) -> bool: + """Clear the tile cache in the database.""" + rows = clear_tile_cache_db() + return rows >= 0 + + def get_tile(self, z: int, x: int, y: int, source_id: str, *, offline: bool) -> bytes | None: + """Retrieve tile from DB or fetch from internet if offline=False. + + Args: + z: Zoom level + x: X coordinate + y: Y coordinate + source_id: Map source identifier + offline: Whether to only check the database + + Returns: + bytes | None: Tile data if found, None otherwise + """ + # Check DB first + tile_data = get_tile_db(z, x, y, source_id) + if tile_data is not None: + return tile_data + + # If offline mode, don't fetch from internet + if offline: + logging.info("Offline mode, tile missing from DB => none returned") + return None + + # Fetch from internet + tile_data = self._fetch_tile(z, x, y, source_id) + if tile_data: + store_tile_db(z, x, y, source_id, tile_data) + return tile_data + + def _fetch_tile(self, z: int, x: int, y: int, source_id: str) -> bytes | None: + ms = MAP_SOURCES.get(source_id) + if not ms: + logging.error("Invalid source_id: %s", source_id) + return None + + url = ms["url_template"].format(z=z, x=x, y=y) + try: + logging.info("Fetching tile from %s", url) + resp = requests.get(url, headers=ms["headers"], timeout=3) + if resp.status_code == HTTPStatus.OK: + return resp.content + logging.warning("Tile fetch returned status %d", resp.status_code) + except requests.RequestException: + logging.info("Network error fetching tile - possibly offline.") + return None diff --git a/radio_telemetry_tracker_drone_gcs/utils/__init__.py b/radio_telemetry_tracker_drone_gcs/utils/__init__.py new file mode 100644 index 0000000..1e4e2c3 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for the RTT Drone GCS.""" diff --git a/radio_telemetry_tracker_drone_gcs/utils/paths.py b/radio_telemetry_tracker_drone_gcs/utils/paths.py new file mode 100644 index 0000000..ea989e9 --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/utils/paths.py @@ -0,0 +1,41 @@ +"""Utility functions for managing application paths.""" + +import os +import sys +from pathlib import Path + +APP_NAME = "Radio Telemetry Tracker Drone Ground Control Station" +APP_DIR_NAME = "RTT-Drone-GCS" # Used for filesystem paths + + +def get_app_dir() -> Path: + """Get the application directory based on environment. + + Returns: + Path: The application directory where persistent files should be stored + """ + if getattr(sys, "frozen", False): + # We're running in a bundle + if sys.platform == "win32": + return Path(os.environ["LOCALAPPDATA"]) / APP_DIR_NAME + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / APP_DIR_NAME + # Linux and other Unix + return Path.home() / ".local" / "share" / APP_DIR_NAME.lower() + # We're running in development + return Path(__file__).parent.parent.parent + + +def ensure_app_dir() -> None: + """Create application directory if it doesn't exist.""" + app_dir = get_app_dir() + app_dir.mkdir(parents=True, exist_ok=True) + + +def get_db_path() -> Path: + """Get the database file path. + + Returns: + Path: Path to the SQLite database file + """ + return get_app_dir() / "rtt_drone_gcs.db" diff --git a/radio_telemetry_tracker_drone_gcs/window.py b/radio_telemetry_tracker_drone_gcs/window.py new file mode 100644 index 0000000..53e353e --- /dev/null +++ b/radio_telemetry_tracker_drone_gcs/window.py @@ -0,0 +1,73 @@ +"""Main PyQt window with QWebEngineView to load the React/Leaflet frontend.""" + +import logging +from pathlib import Path + +from PyQt6.QtCore import QUrl +from PyQt6.QtWebChannel import QWebChannel +from PyQt6.QtWebEngineCore import QWebEngineScript +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWidgets import QMainWindow + + +class MainWindow(QMainWindow): + """Main application window that hosts the web-based frontend using QWebEngineView.""" + + def __init__(self) -> None: + """Initialize the main window and set up the web view with communication bridge.""" + super().__init__() + self.setWindowTitle("Radio Telemetry Tracker Drone GCS") + + self.web_view = QWebEngineView() + self.setCentralWidget(self.web_view) + + self.channel = QWebChannel() + self.bridge = None + self.web_view.page().setWebChannel(self.channel) + + # Dev tools + self.web_view.page().setDevToolsPage(self.web_view.page()) + + self.web_view.loadFinished.connect(self._on_load_finished) + + # Insert QWebChannel script + script = QWebEngineScript() + script.setName("qwebchannel") + script.setSourceCode( + """ + new QWebChannel(qt.webChannelTransport, function(channel) { + window.backend = channel.objects.backend; + window.backendLoaded = true; + const event = new Event('backendLoaded'); + window.dispatchEvent(event); + }); + """, + ) + script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) + script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) + script.setRunsOnSubFrames(False) + self.web_view.page().scripts().insert(script) + + self.resize(1280, 720) + + dist_path = Path(__file__).parent.parent / "frontend" / "dist" / "index.html" + if not dist_path.exists(): + logging.error("Frontend dist not found at %s", dist_path) + msg = f"Frontend dist not found at {dist_path}" + raise FileNotFoundError(msg) + + local_url = QUrl.fromLocalFile(str(dist_path)) + logging.info("Loading frontend from %s", local_url.toString()) + self.web_view.setUrl(local_url) + + def _on_load_finished(self, *, ok: bool = True) -> None: + if ok: + logging.info("Frontend loaded successfully") + else: + logging.error("Failed to load frontend") + + def set_bridge(self, bridge: object) -> None: + """Register the communication bridge object with the web channel.""" + self.bridge = bridge + self.channel.registerObject("backend", bridge) + logging.info("Bridge registered with WebChannel") diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..3c490e0 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Build and development scripts for Radio Telemetry Tracker Drone GCS.""" diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..60d7a2a --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,42 @@ +"""Build script for creating an executable with PyInstaller. + +This script also handles building the frontend before bundling everything. +""" + +import logging +import subprocess +from pathlib import Path + +from radio_telemetry_tracker_drone_gcs.utils.paths import APP_NAME +from scripts.utils import build_frontend + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Build the executable using PyInstaller.""" + root_dir = Path(__file__).parent.parent + frontend_dir = build_frontend() + + cmd = [ + "pyinstaller", + f"--name={APP_NAME}", + "--windowed", + "--onefile", + "--add-data", + f"{frontend_dir / 'dist'}:frontend/dist", + # Add hidden imports for path utilities + "--hidden-import=radio_telemetry_tracker_drone_gcs.utils.paths", + ] + + # Optional: add an icon if you have one in assets/ + icon_path = root_dir / "assets" / "icon.ico" + if icon_path.exists(): + cmd.extend(["--icon", str(icon_path)]) + + # Main script + cmd.append(str(root_dir / "radio_telemetry_tracker_drone_gcs" / "main.py")) + + logger.info("Building executable with PyInstaller...") + subprocess.run(cmd, check=True) # noqa: S603 + logger.info("Build complete! Executable can be found in the 'dist' directory.") diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100644 index 0000000..474eec4 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,13 @@ +"""Development script for running the application. + +Automatically builds the frontend, then runs the Python main entry point. +""" + +from radio_telemetry_tracker_drone_gcs.main import main as app_main +from scripts.utils import build_frontend + + +def main() -> None: + """Build frontend and run the app in development mode.""" + build_frontend() + app_main() diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 0000000..ee1ae2c --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,40 @@ +"""Shared utility functions for build and development scripts.""" + +import logging +import platform +import subprocess +from pathlib import Path + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +NPM_CMD = "npm.cmd" if platform.system() == "Windows" else "npm" + +ALLOWED_COMMANDS = { + NPM_CMD: ["run", "build"], +} + + +def validate_command(cmd: list[str]) -> bool: + """Ensure the command is in the allowed list for security reasons.""" + if not cmd: + return False + program = cmd[0] + return program in ALLOWED_COMMANDS and all(arg in ALLOWED_COMMANDS[program] for arg in cmd[1:]) + + +def build_frontend() -> Path: + """Build the frontend using npm. + + Returns the path to the frontend directory. + """ + frontend_dir = Path(__file__).parent.parent / "frontend" + logger.info("Building frontend...") + + cmd = [NPM_CMD, "run", "build"] + if not validate_command(cmd): + msg = "Invalid or disallowed command for building frontend." + raise ValueError(msg) + + subprocess.run(cmd, cwd=frontend_dir, check=True, text=True) # noqa: S603 + return frontend_dir diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..49e5d33 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for RTT Drone GCS.""" diff --git a/tests/comms/__init__.py b/tests/comms/__init__.py new file mode 100644 index 0000000..4e8a91e --- /dev/null +++ b/tests/comms/__init__.py @@ -0,0 +1,4 @@ +"""Tests for RTT Drone GCS communication modules. + +This package contains tests for drone communication and state management functionality. +""" diff --git a/tests/comms/test_communication_bridge.py b/tests/comms/test_communication_bridge.py new file mode 100644 index 0000000..6b8b4c7 --- /dev/null +++ b/tests/comms/test_communication_bridge.py @@ -0,0 +1,117 @@ +"""Tests for the communication bridge module. + +This module contains tests for the communication bridge class, which manages the connection +and communication with the drone. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from pytestqt.qtbot import QtBot + +from radio_telemetry_tracker_drone_gcs.comms.communication_bridge import CommunicationBridge + + +@pytest.fixture +def communication_bridge() -> CommunicationBridge: + """Fixture that returns a CommunicationBridge instance.""" + bridge = CommunicationBridge() + # Add test helper method + bridge.get_comms_service = lambda: bridge._comms_service # type: ignore # noqa: PGH003, SLF001 + bridge.set_comms_service = lambda x: setattr(bridge, "_comms_service", x) # type: ignore # noqa: PGH003 + return bridge + + +def test_initialize_comms_success(qtbot: QtBot, communication_bridge: CommunicationBridge) -> None: # noqa: ARG001 + """Test a successful initialize_comms call.""" + mock_comms_service = MagicMock() + with patch( + "radio_telemetry_tracker_drone_gcs.comms.communication_bridge.DroneCommsService", + return_value=mock_comms_service, + ): + config = { + "interface_type": "serial", + "port": "COM4", + "baudrate": 115200, + "host": "", + "tcp_port": 0, + "ack_timeout": 3, + "max_retries": 2, + } + + success = communication_bridge.initialize_comms(config) + assert success is True # noqa: S101 + mock_comms_service.start.assert_called_once() + mock_comms_service.send_sync_request.assert_called_once() + + # We can also advance the QTimer to simulate no response or mock the response. + + +def test_initialize_comms_failure(communication_bridge: CommunicationBridge) -> None: + """Test initialize_comms call that fails with an exception.""" + with patch( + "radio_telemetry_tracker_drone_gcs.comms.communication_bridge.DroneCommsService", + side_effect=Exception("Test failure"), + ): + config = { + "interface_type": "serial", + "port": "COM4", + "baudrate": 115200, + "host": "", + "tcp_port": 0, + "ack_timeout": 3, + "max_retries": 2, + } + success = communication_bridge.initialize_comms(config) + assert success is False # noqa: S101 + + +def test_cancel_connection(communication_bridge: CommunicationBridge) -> None: + """Test cancelling a connection attempt.""" + # Simulate an active comms service + mock_comms_service = MagicMock() + communication_bridge.set_comms_service(mock_comms_service) + communication_bridge.cancel_connection() + + assert communication_bridge.get_comms_service() is None # noqa: S101 + mock_comms_service.stop.assert_called_once() + + +def test_disconnect_no_service(communication_bridge: CommunicationBridge, qtbot: QtBot) -> None: # noqa: ARG001 + """Test disconnect when there's no active comms service.""" + + # We can listen for a signal or log message + def on_success(msg: str) -> None: + assert "UNDEFINED BEHAVIOR" in msg # noqa: S101 + + communication_bridge.disconnect_success.connect(on_success) + communication_bridge.disconnect() + + +def test_send_config_request_success(communication_bridge: CommunicationBridge) -> None: + """Test sending a config request successfully.""" + mock_comms_service = MagicMock() + communication_bridge.set_comms_service(mock_comms_service) + cfg = { + "gain": 10, + "sampling_rate": 48000, + "center_frequency": 1000000, + "enable_test_data": True, + "ping_width_ms": 5, + "ping_min_snr": 20, + "ping_max_len_mult": 1.5, + "ping_min_len_mult": 0.5, + "target_frequencies": [100000, 200000], + } + success = communication_bridge.send_config_request(cfg) + assert success is True # noqa: S101 + mock_comms_service.send_config_request.assert_called_once() + + +def test_send_start_request_no_service(communication_bridge: CommunicationBridge) -> None: + """Test sending a start request when _comms_service is None.""" + communication_bridge.set_comms_service(None) + # We can connect a slot to start_failure to confirm it emitted + with patch.object(communication_bridge, "start_failure") as mock_signal: + communication_bridge.send_start_request() + mock_signal.emit.assert_called_once() diff --git a/tests/comms/test_drone_comms_service.py b/tests/comms/test_drone_comms_service.py new file mode 100644 index 0000000..529f654 --- /dev/null +++ b/tests/comms/test_drone_comms_service.py @@ -0,0 +1,72 @@ +"""Tests for the drone communications service module. + +This module contains tests for drone communication service initialization, start/stop functionality, +and message handling. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from radio_telemetry_tracker_drone_comms_package import RadioConfig + +from radio_telemetry_tracker_drone_gcs.comms.drone_comms_service import DroneCommsService + + +@pytest.fixture +def mock_drone_comms() -> MagicMock: + """Fixture to create a mock DroneComms instance.""" + with patch("radio_telemetry_tracker_drone_gcs.comms.drone_comms_service.DroneComms") as mock_class: + yield mock_class + + +@pytest.fixture +def drone_comms_service(mock_drone_comms: MagicMock) -> DroneCommsService: # noqa: ARG001 + """Return a DroneCommsService with a mocked DroneComms instance.""" + radio_cfg = RadioConfig( + interface_type="serial", + port="COM3", + baudrate=9600, + host="", + tcp_port=0, + server_mode=False, + ) + service = DroneCommsService( + radio_config=radio_cfg, + ack_timeout=2.0, + max_retries=3, + ) + # Add test helper method + service.get_comms = lambda: service._comms # type: ignore # noqa: PGH003, SLF001 + return service + + +def test_start_stop(drone_comms_service: DroneCommsService) -> None: + """Test starting and stopping the DroneCommsService.""" + assert not drone_comms_service.is_started() # noqa: S101 + + # Start + drone_comms_service.start() + assert drone_comms_service.is_started() # noqa: S101 + drone_comms_service.get_comms().start.assert_called_once() + + # Stop + drone_comms_service.stop() + assert not drone_comms_service.is_started() # noqa: S101 + drone_comms_service.get_comms().stop.assert_called_once() + + +def test_register_handlers(drone_comms_service: DroneCommsService) -> None: + """Test registering sync response handler.""" + mock_callback = MagicMock() + drone_comms_service.register_sync_response_handler(mock_callback, once=True) + drone_comms_service.get_comms().register_sync_response_handler.assert_called_once_with(mock_callback, once=True) + + +def test_send_sync_request(drone_comms_service: DroneCommsService) -> None: + """Test that send_sync_request calls DroneComms.send_sync_request correctly.""" + packet_id = 123 + drone_comms_service.get_comms().send_sync_request.return_value = (packet_id, True, 1.0) + + result = drone_comms_service.send_sync_request() + drone_comms_service.get_comms().send_sync_request.assert_called_once() + assert result == packet_id # noqa: S101 diff --git a/tests/comms/test_state_machine.py b/tests/comms/test_state_machine.py new file mode 100644 index 0000000..4caf848 --- /dev/null +++ b/tests/comms/test_state_machine.py @@ -0,0 +1,69 @@ +"""Tests for the drone state machine module. + +This module contains tests for state transitions and timeout handling in the drone state machine. +""" + +import pytest + +from radio_telemetry_tracker_drone_gcs.comms.state_machine import ( + DroneState, + DroneStateMachine, + StateTransition, +) + + +@pytest.fixture +def state_machine() -> DroneStateMachine: + """Fixture providing a DroneStateMachine instance for testing.""" + return DroneStateMachine() + + +def test_initial_state(state_machine: DroneStateMachine) -> None: + """Test that the initial state of the state machine is RADIO_CONFIG_INPUT.""" + assert state_machine.current_state == DroneState.RADIO_CONFIG_INPUT # noqa: S101 + + +def test_valid_transition(state_machine: DroneStateMachine) -> None: + """Test a valid transition from RADIO_CONFIG_INPUT -> RADIO_CONFIG_WAITING.""" + transition = StateTransition( + from_state=DroneState.RADIO_CONFIG_INPUT, + to_state=DroneState.RADIO_CONFIG_WAITING, + success_message="Connected", + failure_message="Connection failed", + ) + + state_machine.transition_to(DroneState.RADIO_CONFIG_WAITING, transition) + assert state_machine.current_state == DroneState.RADIO_CONFIG_WAITING # noqa: S101 + + +def test_invalid_transition(state_machine: DroneStateMachine) -> None: + """Test an invalid transition fails and triggers state_error signal.""" + # Attempt a transition that doesn't match the from_state + transition = StateTransition( + from_state=DroneState.START_WAITING, + to_state=DroneState.STOP_WAITING, + success_message="Should not happen", + failure_message="Wrong from_state", + ) + + state_machine.transition_to(DroneState.STOP_WAITING, transition) + assert state_machine.current_state != DroneState.STOP_WAITING # noqa: S101 + assert state_machine.current_state == DroneState.RADIO_CONFIG_INPUT # noqa: S101 + + +def test_timeout_handling(state_machine: DroneStateMachine) -> None: + """Test handle_timeout transitions WAITING states to TIMEOUT states.""" + # Move from RADIO_CONFIG_INPUT -> RADIO_CONFIG_WAITING first + transition = StateTransition( + from_state=DroneState.RADIO_CONFIG_INPUT, + to_state=DroneState.RADIO_CONFIG_WAITING, + success_message="Connected", + failure_message="Connection failed", + ) + state_machine.transition_to(DroneState.RADIO_CONFIG_WAITING, transition) + assert state_machine.current_state == DroneState.RADIO_CONFIG_WAITING # noqa: S101 + + # Now handle_timeout + state_machine.handle_timeout() + # Should transition from RADIO_CONFIG_WAITING -> RADIO_CONFIG_TIMEOUT + assert state_machine.current_state == DroneState.RADIO_CONFIG_TIMEOUT # noqa: S101 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..51bb3b5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Configuration for pytest.""" diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..eaaf1af --- /dev/null +++ b/tests/data/__init__.py @@ -0,0 +1,4 @@ +"""Tests for RTT Drone GCS data management modules. + +This package contains tests for data handling and state management functionality. +""" diff --git a/tests/data/test_drone_data_manager.py b/tests/data/test_drone_data_manager.py new file mode 100644 index 0000000..f07e1fc --- /dev/null +++ b/tests/data/test_drone_data_manager.py @@ -0,0 +1,87 @@ +"""Tests for the drone data manager module. + +This module contains tests for GPS, ping, and location estimate data management. +""" + +import pytest +from pytestqt.qtbot import QtBot + +from radio_telemetry_tracker_drone_gcs.data.drone_data_manager import DroneDataManager +from radio_telemetry_tracker_drone_gcs.data.models import GpsData, LocEstData, PingData + +# Test constants +TEST_FREQUENCY = 150000 # Hz +TEST_FREQUENCY_2 = 200000 # Hz +EXPECTED_FREQUENCY_COUNT = 2 # Number of test frequencies used in tests + + +@pytest.fixture +def data_manager() -> DroneDataManager: + """Fixture providing a DroneDataManager instance for testing.""" + return DroneDataManager() + + +def test_update_gps(data_manager: DroneDataManager, qtbot: QtBot) -> None: # noqa: ARG001 + """Test that GPS data is updated and signal is emitted.""" + gps_signal_received = [] + + def on_gps(qvar: object) -> None: + gps_signal_received.append(qvar) + + data_manager.gps_data_updated.connect(on_gps) + + gps = GpsData(lat=32.88, long=-117.24, altitude=5.0, heading=90.0, timestamp=1234567890, packet_id=1) + data_manager.update_gps(gps) + + assert len(gps_signal_received) == 1 # noqa: S101 + + +def test_add_ping(data_manager: DroneDataManager, qtbot: QtBot) -> None: # noqa: ARG001 + """Test that a PingData is added and signal is emitted.""" + freq_signal_received = [] + + def on_freq(qvar: object) -> None: + freq_signal_received.append(qvar) + + data_manager.frequency_data_updated.connect(on_freq) + + ping = PingData( + frequency=TEST_FREQUENCY, + amplitude=10.0, + lat=32.88, + long=-117.24, + timestamp=1234567891, + packet_id=2, + ) + data_manager.add_ping(ping) + + assert len(freq_signal_received) == 1 # noqa: S101 + + +def test_update_loc_est(data_manager: DroneDataManager) -> None: + """Test updating location estimates.""" + loc_est = LocEstData(frequency=TEST_FREQUENCY, lat=32.5, long=-117.0, timestamp=1234567892, packet_id=3) + data_manager.update_loc_est(loc_est) + + +def test_clear_frequency_data(data_manager: DroneDataManager) -> None: + """Test clearing frequency data for a specific frequency.""" + ping = PingData(frequency=TEST_FREQUENCY, amplitude=10.0, lat=32.88, long=-117.24, timestamp=1, packet_id=1) + data_manager.add_ping(ping) + assert data_manager.has_frequency(TEST_FREQUENCY) # noqa: S101 + + data_manager.clear_frequency_data(TEST_FREQUENCY) + assert not data_manager.has_frequency(TEST_FREQUENCY) # noqa: S101 + + +def test_clear_all_frequency_data(data_manager: DroneDataManager) -> None: + """Test clearing all frequency data.""" + ping1 = PingData(frequency=TEST_FREQUENCY, amplitude=10.0, lat=32.88, long=-117.24, timestamp=1, packet_id=1) + ping2 = PingData(frequency=TEST_FREQUENCY_2, amplitude=8.0, lat=32.70, long=-117.20, timestamp=2, packet_id=2) + data_manager.add_ping(ping1) + data_manager.add_ping(ping2) + + assert len(data_manager.get_frequencies()) == EXPECTED_FREQUENCY_COUNT # noqa: S101 + + data_manager.clear_all_frequency_data() + assert len(data_manager.get_frequencies()) == 0 # noqa: S101 diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..5415559 --- /dev/null +++ b/tests/services/__init__.py @@ -0,0 +1 @@ +"""Tests for RTT Drone GCS services.""" diff --git a/tests/services/test_poi_service.py b/tests/services/test_poi_service.py new file mode 100644 index 0000000..ea2335f --- /dev/null +++ b/tests/services/test_poi_service.py @@ -0,0 +1,60 @@ +"""Tests for the POI service module. + +This module contains tests for POI (Points of Interest) creation, retrieval, and management. +""" + +from unittest.mock import patch + +import pytest + +from radio_telemetry_tracker_drone_gcs.services.poi_service import PoiService + + +@pytest.fixture +def poi_service() -> PoiService: + """Fixture providing a PoiService instance for testing.""" + return PoiService() + + +def test_get_pois(poi_service: PoiService) -> None: + """Verify that get_pois does not error out and returns a list.""" + with patch( + "radio_telemetry_tracker_drone_gcs.services.poi_service.list_pois_db", + return_value=[], + ) as mock_list: + pois = poi_service.get_pois() + assert isinstance(pois, list) # noqa: S101 + mock_list.assert_called_once() + + +def test_add_poi(poi_service: PoiService) -> None: + """Test adding a POI (happy path).""" + with patch( + "radio_telemetry_tracker_drone_gcs.services.poi_service.add_poi_db", + return_value=True, + ) as mock_add: + result = poi_service.add_poi("TestPOI", [32.88, -117.24]) + assert result is True # noqa: S101 + mock_add.assert_called_once_with("TestPOI", 32.88, -117.24) + + +def test_remove_poi(poi_service: PoiService) -> None: + """Test removing a POI.""" + with patch( + "radio_telemetry_tracker_drone_gcs.services.poi_service.remove_poi_db", + return_value=True, + ) as mock_remove: + result = poi_service.remove_poi("TestPOI") + assert result is True # noqa: S101 + mock_remove.assert_called_once_with("TestPOI") + + +def test_rename_poi(poi_service: PoiService) -> None: + """Test renaming a POI.""" + with patch( + "radio_telemetry_tracker_drone_gcs.services.poi_service.rename_poi_db", + return_value=True, + ) as mock_rename: + result = poi_service.rename_poi("OldPOI", "NewPOI") + assert result is True # noqa: S101 + mock_rename.assert_called_once_with("OldPOI", "NewPOI") diff --git a/tests/services/test_tile_service.py b/tests/services/test_tile_service.py new file mode 100644 index 0000000..3d62656 --- /dev/null +++ b/tests/services/test_tile_service.py @@ -0,0 +1,89 @@ +"""Tests for the tile service module. + +This module contains tests for tile fetching, caching, and management functionality. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from radio_telemetry_tracker_drone_gcs.services.tile_service import TileService + + +@pytest.fixture +def tile_service() -> TileService: + """Fixture providing a TileService instance for testing.""" + return TileService() + + +def test_get_tile_info(tile_service: TileService) -> None: + """Test retrieving tile cache information.""" + with patch( + "radio_telemetry_tracker_drone_gcs.services.tile_service.get_tile_info_db", + return_value={"total_tiles": 0, "total_size_mb": 0}, + ) as mock_info: + info = tile_service.get_tile_info() + assert "total_tiles" in info # noqa: S101 + assert "total_size_mb" in info # noqa: S101 + mock_info.assert_called_once() + + +def test_clear_tile_cache(tile_service: TileService) -> None: + """Test clearing the tile cache.""" + with patch( + "radio_telemetry_tracker_drone_gcs.services.tile_service.clear_tile_cache_db", + return_value=10, + ) as mock_clear: + success = tile_service.clear_tile_cache() + assert success is True # noqa: S101 + mock_clear.assert_called_once() + + +def test_get_tile_offline(tile_service: TileService) -> None: + """Test requesting a tile in offline mode that does not exist in DB => returns None.""" + with patch( + "radio_telemetry_tracker_drone_gcs.services.tile_service.get_tile_db", + return_value=None, + ) as mock_get: + data = tile_service.get_tile(1, 2, 3, "osm", offline=True) + assert data is None # noqa: S101 + mock_get.assert_called_once_with(1, 2, 3, "osm") + + +def test_get_tile_db_cached(tile_service: TileService) -> None: + """Test requesting a tile that is cached in the DB.""" + fake_tile_data = b"FAKE_TILE" + with patch( + "radio_telemetry_tracker_drone_gcs.services.tile_service.get_tile_db", + return_value=fake_tile_data, + ) as mock_get: + data = tile_service.get_tile(1, 2, 3, "osm", offline=False) + assert data == fake_tile_data # noqa: S101 + mock_get.assert_called_once_with(1, 2, 3, "osm") + + +def test_fetch_tile_http_success(tile_service: TileService) -> None: + """Test fetching a tile from the internet when not in DB.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"MOCK_TILE_DATA" + + with ( + patch( + "radio_telemetry_tracker_drone_gcs.services.tile_service.get_tile_db", + return_value=None, + ) as mock_get, + patch( + "radio_telemetry_tracker_drone_gcs.services.tile_service.requests.get", + return_value=mock_response, + ) as mock_http, + patch( + "radio_telemetry_tracker_drone_gcs.services.tile_service.store_tile_db", + return_value=True, + ) as mock_store, + ): + data = tile_service.get_tile(1, 2, 3, "osm", offline=False) + assert data == b"MOCK_TILE_DATA" # noqa: S101 + mock_get.assert_called_once_with(1, 2, 3, "osm") + mock_store.assert_called_once_with(1, 2, 3, "osm", b"MOCK_TILE_DATA") + mock_http.assert_called_once()