Initial commit: GSPro Remote MVP - Phase 1 complete
This commit is contained in:
commit
74ca4b38eb
50 changed files with 12818 additions and 0 deletions
47
.editorconfig
Normal file
47
.editorconfig
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Universal settings
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
# Python files
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
|
max_line_length = 120
|
||||||
|
|
||||||
|
# TypeScript, JavaScript, JSX, TSX files
|
||||||
|
[*.{ts,tsx,js,jsx}]
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 100
|
||||||
|
|
||||||
|
# JSON, YAML files
|
||||||
|
[*.{json,yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# HTML, CSS, SCSS files
|
||||||
|
[*.{html,css,scss}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Markdown files
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
max_line_length = 120
|
||||||
|
|
||||||
|
# PowerShell scripts
|
||||||
|
[*.{ps1,psm1}]
|
||||||
|
end_of_line = crlf
|
||||||
|
|
||||||
|
# Windows batch files
|
||||||
|
[*.{bat,cmd}]
|
||||||
|
end_of_line = crlf
|
||||||
|
|
||||||
|
# Makefiles
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
61
.gitignore
vendored
Normal file
61
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.hypothesis/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
backend/ui/
|
||||||
|
*.exe
|
||||||
|
*.spec
|
||||||
|
*.msi
|
||||||
|
|
||||||
|
# Config (keep templates, ignore actual)
|
||||||
|
config.json
|
||||||
|
!config.template.json
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
BIN
GSPro App.png
Normal file
BIN
GSPro App.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
229
PRD.md
Normal file
229
PRD.md
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# Product Requirements Document (PRD)
|
||||||
|
|
||||||
|
**Product:** GSPro Remote
|
||||||
|
**Version:** 0.1.0 (Restart Foundation)
|
||||||
|
**Author:** \[You]
|
||||||
|
**Date:** \[Today’s date]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
GSPro Remote is a companion application for **GSPro golf simulator users** running on Windows. It allows golfers to control GSPro from a secondary device (tablet, phone, or PC) via a web-based interface.
|
||||||
|
|
||||||
|
The app’s purpose is to:
|
||||||
|
|
||||||
|
* Simplify control of GSPro without needing a physical keyboard nearby.
|
||||||
|
* Provide intuitive UI for common simulator functions (aim, club selection, mulligans, tee box navigation, shot options, map interaction).
|
||||||
|
* Enhance immersion and ease-of-use for golf enthusiasts with GSPro setups.
|
||||||
|
|
||||||
|
Future versions may incorporate **computer vision (OCR/marker detection)** to auto-detect and track GSPro UI elements, reducing manual setup and enabling more advanced automation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **Deliver a clean, intuitive remote-control interface** optimized for tablets/phones.
|
||||||
|
2. **Support all GSPro keyboard shortcuts** via touch-friendly buttons.
|
||||||
|
3. **Provide low-latency streaming of the GSPro map/mini-map** (\~200ms or less).
|
||||||
|
4. **Persist user settings/configuration** locally on the GSPro PC between sessions.
|
||||||
|
5. **Make installation simple** for non-technical users (one installer, auto-start server).
|
||||||
|
6. **Prepare for future OCR-based automation**, but keep it out of MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Non-Goals
|
||||||
|
|
||||||
|
* Multiplayer sync or cloud-hosted services. (There is zero value in a any cloud related services)
|
||||||
|
* Mobile native apps (PWA installable from browser is sufficient).
|
||||||
|
* Full-fledged vision debugging UI for end-users (that stays an advanced/dev option).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Target Audience
|
||||||
|
|
||||||
|
* **Primary:** GSPro simulator owners with a launch monitor, seeking more convenient in-round controls.
|
||||||
|
* **Secondary:** Enthusiasts who like to customize sim setups (may use advanced OCR features later).
|
||||||
|
|
||||||
|
Users are assumed to be **non-technical golfers**, not developers. Installation and updates must be straightforward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Personas & Use Cases
|
||||||
|
|
||||||
|
### Persona A: “Weekend Golfer”
|
||||||
|
|
||||||
|
* Wants to control GSPro from a tablet while standing on the mat.
|
||||||
|
* Needs quick access to Aim, Mulligan, Club Selection, Tee Box controls.
|
||||||
|
* Doesn’t want to touch a keyboard during play.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Functional Requirements
|
||||||
|
|
||||||
|
### 6.1 Core Features (MVP)
|
||||||
|
|
||||||
|
* **UI Controls**
|
||||||
|
|
||||||
|
* Directional Pad for Aim (Up/Down/Left/Right/Reset).
|
||||||
|
* Club Indicator + Detection (manual at first, OCR planned later).
|
||||||
|
* Mulligan button.
|
||||||
|
* Tee box navigation (previous/next tee).
|
||||||
|
* Map panel with expand/collapse. (should stream the map from the GSPro UI)
|
||||||
|
|
||||||
|
* **Backend Integration**
|
||||||
|
|
||||||
|
* WebSocket streaming of GSPro map region.
|
||||||
|
* API endpoints to send keypresses mapped to GSPro shortcuts.
|
||||||
|
* Config persistence (JSON stored on host PC).
|
||||||
|
* mDNS discovery (`gsproapp.local`) for easy access.
|
||||||
|
|
||||||
|
* **Performance**
|
||||||
|
|
||||||
|
* Streaming latency under 200ms (720p @ 30fps acceptable).
|
||||||
|
* Smooth interaction (key commands executed <100ms).
|
||||||
|
|
||||||
|
### 6.2 Advanced / V2 Features
|
||||||
|
|
||||||
|
* **OCR & Region Detection**
|
||||||
|
|
||||||
|
* EasyOCR/Tesseract integration to auto-detect club, map, shot info.
|
||||||
|
* Auto-configuration of regions (user doesn’t manually define).
|
||||||
|
* **Custom Monitor Tasks**
|
||||||
|
|
||||||
|
* Background monitoring of regions for changes (e.g., “detect when putting mode enabled”).
|
||||||
|
* **Marker Detection**
|
||||||
|
|
||||||
|
* Visual template matching for advanced element tracking.
|
||||||
|
* **Extended Controls**
|
||||||
|
|
||||||
|
* Support for additional GSPro shortcuts (e.g., free look, flyover, lighting).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Technical Architecture
|
||||||
|
|
||||||
|
* **Frontend:** React + TypeScript
|
||||||
|
|
||||||
|
* `DynamicGolfUI` = Main consumer UI (touch-friendly, polished).
|
||||||
|
* `VisionDashboard` = Developer/advanced-only UI (OCR testing, markers).
|
||||||
|
|
||||||
|
* **Backend:** Python + FastAPI
|
||||||
|
|
||||||
|
* `/api/actions/key` → Trigger GSPro keypresses.
|
||||||
|
* `/api/vision/ws/stream` → WebSocket for video streaming.
|
||||||
|
* `/api/vision/ocr`, `/api/vision/markers`, `/api/vision/regions` → Vision endpoints (advanced).
|
||||||
|
|
||||||
|
* **System Design**
|
||||||
|
|
||||||
|
* Runs locally on Windows (FastAPI server).
|
||||||
|
* UI served at `http://gsproapp.local:5005/ui`.
|
||||||
|
* Input simulated via `pydirectinput` to control GSPro.
|
||||||
|
* Screen captured via `mss` + OpenCV.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. User Experience
|
||||||
|
|
||||||
|
* **Primary UI (DynamicGolfUI)**
|
||||||
|
Matches "GSPro App.png" in project root directory:
|
||||||
|
|
||||||
|
* Top: App name/version, Mulligan button.
|
||||||
|
* Left: Club indicator.
|
||||||
|
* Center: Aim directional pad.
|
||||||
|
* Right: Map stream (expandable).
|
||||||
|
* Bottom: Shot Options + Tee controls.
|
||||||
|
|
||||||
|
* **Advanced UI (VisionDashboard)**
|
||||||
|
Hidden behind advanced toggle. Used by devs to:
|
||||||
|
|
||||||
|
* Test OCR engines.
|
||||||
|
* Define/capture regions.
|
||||||
|
* Add/verify markers.
|
||||||
|
* Monitor tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Success Metrics
|
||||||
|
|
||||||
|
* MVP launch:
|
||||||
|
|
||||||
|
* Users can control 90% of their round without a keyboard.
|
||||||
|
* Map streaming latency <200ms.
|
||||||
|
* 1-click installer on Windows.
|
||||||
|
* V2:
|
||||||
|
|
||||||
|
* Automatic detection of club + map area with >90% reliability.
|
||||||
|
* Config persistence across restarts without user intervention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Milestones
|
||||||
|
|
||||||
|
* **Phase 1 (MVP / Public Beta)**
|
||||||
|
|
||||||
|
* Polish DynamicGolfUI.
|
||||||
|
* Ensure all GSPro shortcuts are covered.
|
||||||
|
* Stable WebSocket streaming.
|
||||||
|
* Windows installer packaging.
|
||||||
|
|
||||||
|
* **Phase 2 (Vision + OCR)**
|
||||||
|
|
||||||
|
* Add OCR-based auto-detection.
|
||||||
|
* Background monitor tasks.
|
||||||
|
* VisionDashboard improvements.
|
||||||
|
|
||||||
|
* **Phase 3 (Subscription-ready)**
|
||||||
|
|
||||||
|
* Licensing/subscription integration.
|
||||||
|
* Polished UX/UI redesign (visual depth, animations, better icons).
|
||||||
|
* Documentation & onboarding guide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risks / Open Questions
|
||||||
|
|
||||||
|
* Will OCR performance be good enough at different resolutions (1080p vs 4K)?
|
||||||
|
* Will average users struggle with network/firewall setup for accessing the web UI?
|
||||||
|
* Packaging FastAPI + dependencies into an easy Windows installer (PyInstaller vs. MSI vs. Docker)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Feature Mapping Matrix
|
||||||
|
|
||||||
|
| **UI Control** | **GSPro Shortcut** | **API Endpoint** | **Notes** |
|
||||||
|
| ----------------------------------------- | ------------------------------- | ----------------------------------------- | ---------------------------------------------- |
|
||||||
|
| **Reset** | `A` | `POST /api/actions/key` | Reset |
|
||||||
|
| **Aim Pad (Up/Down/Left/Right)** | Arrow keys | `POST /api/actions/keydown` + `/keyup` | aim adjust = arrow keys, needs “hold” support |
|
||||||
|
| **Club Selection (Up/Down)** | `U` (club up), `K` (club down) | `POST /api/actions/key` | Shown as club indicator in UI |
|
||||||
|
| **Mulligan** | `Ctrl+M` | `POST /api/actions/key` | Toggle mulligan |
|
||||||
|
| **Shot Options (Normal/Punch/Flop/Chip)** | `'` (apostrophe) + context? | `POST /api/actions/key` | Might need mapping to GSPro UI if not explicit |
|
||||||
|
| **Tee Box (Left/Right)** | `C` (tee left), `V` (tee right) | `POST /api/actions/key` | Navigates tee position |
|
||||||
|
| **Map Expand/Collapse** | `S` | `POST /api/actions/key` | Toggles map size |
|
||||||
|
| **Map Zoom (In/Out)** | `Q` (zoom +), `W` (zoom -) | `POST /api/actions/key` | Could be UI slider later |
|
||||||
|
| **Map Click (Expanded)** | Mouse click | `POST /api/actions/key` or WS click event | Normalized coordinates mapped to screen |
|
||||||
|
| **Scorecard** | `T` | `POST /api/actions/key` | Toggle scorecard |
|
||||||
|
| **Range Finder** | `R` | `POST /api/actions/key` | Launch rangefinder |
|
||||||
|
| **Heat Map** | `Y` | `POST /api/actions/key` | Toggle heatmap overlay |
|
||||||
|
| **Putt Toggle** | `U` | `POST /api/actions/key` | Enable/disable putting mode |
|
||||||
|
| **Pin Indicator** | `P` | `POST /api/actions/key` | Show/hide pin indicator |
|
||||||
|
| **Flyover** | `O` | `POST /api/actions/key` | Hole preview |
|
||||||
|
| **Tracer Clear** | `F1` | `POST /api/actions/key` | Clear ball tracer |
|
||||||
|
| **Aim Point** | `F3` | `POST /api/actions/key` | Toggle aim point view |
|
||||||
|
| **Free Look** | `F5` | `POST /api/actions/key` | Unlock camera |
|
||||||
|
| **Console Short/Tall** | `F8` / `F9` | `POST /api/actions/key` | Open GSPro dev consoles |
|
||||||
|
| **Fullscreen** | `F11` | `POST /api/actions/key` | Toggle fullscreen |
|
||||||
|
| **Camera Go To Ball** | `5` | `POST /api/actions/key` | Jump to ball cam |
|
||||||
|
| **Camera Fly To Ball** | `6` | `POST /api/actions/key` | Fly cam to ball |
|
||||||
|
| **Go To Ball (practice)** | `8` | `POST /api/actions/key` | Only in practice mode |
|
||||||
|
| **Previous/Next Hole** | `9` / `0` | `POST /api/actions/key` | Only in practice mode |
|
||||||
|
| **Sound On/Off** | `+` / `-` | `POST /api/actions/key` | Volume control |
|
||||||
|
| **FPS Display** | `F` | `POST /api/actions/key` | Show/hide FPS |
|
||||||
|
| **Green Grid** | `G` | `POST /api/actions/key` | Show/hide green putting grid |
|
||||||
|
| **Lighting** | `L` | `POST /api/actions/key` | Adjust lighting |
|
||||||
|
| **Shot Camera** | `J` | `POST /api/actions/key` | Switch to shot cam |
|
||||||
|
| **UI Hide/Show** | `H` | `POST /api/actions/key` | Toggle GSPro UI |
|
||||||
|
| **3D Grass Toggle** | `Z` | `POST /api/actions/key` | Show/hide grass detail |
|
||||||
|
| **Switch Handedness** | `N` | `POST /api/actions/key` | Left/right handed player |
|
||||||
|
| **Shadow Intensity** | `<` / `>` | `POST /api/actions/key` | Adjust shadows |
|
||||||
|
| **Fast Forward Ball Roll** | Hold key (space?) | `POST /api/actions/keydown` + `/keyup` | Needs “hold” support |
|
||||||
212
README.md
Normal file
212
README.md
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
# GSPro Remote
|
||||||
|
|
||||||
|
A companion application for GSPro golf simulator that allows remote control from any device on your network.
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
GSPro Remote is a web-based remote control application for GSPro golf simulator running on Windows. Control GSPro from your tablet, phone, or another PC without needing a keyboard nearby.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Touch-Friendly Interface** - Optimized for tablets and phones
|
||||||
|
- **All GSPro Shortcuts** - Full keyboard shortcut support via touch controls
|
||||||
|
- **Live Map Streaming** - Real-time streaming of the GSPro mini-map
|
||||||
|
- **mDNS Discovery** - Access via `gsproapp.local` on your network
|
||||||
|
- **Persistent Settings** - Configuration saved between sessions
|
||||||
|
- **Zero Cloud Dependencies** - Everything runs locally on your network
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Windows PC running GSPro
|
||||||
|
- Python 3.11+
|
||||||
|
- Node.js 20+
|
||||||
|
- GSPro golf simulator
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/gspro-remote.git
|
||||||
|
cd gspro-remote
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the development setup:
|
||||||
|
```powershell
|
||||||
|
.\scripts\dev.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Install all backend dependencies
|
||||||
|
- Install all frontend dependencies
|
||||||
|
- Start both development servers
|
||||||
|
- Open the UI at http://localhost:5173
|
||||||
|
|
||||||
|
### Access Points
|
||||||
|
|
||||||
|
- **Frontend UI**: http://localhost:5173
|
||||||
|
- **Backend API**: http://localhost:5005
|
||||||
|
- **API Documentation**: http://localhost:5005/api/docs
|
||||||
|
- **mDNS Access**: http://gsproapp.local:5005
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gspro-remote/
|
||||||
|
├── backend/ # Python FastAPI backend
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/ # API endpoints
|
||||||
|
│ │ │ ├── actions.py # Keyboard control
|
||||||
|
│ │ │ ├── vision.py # Screen streaming
|
||||||
|
│ │ │ ├── config.py # Configuration
|
||||||
|
│ │ │ └── system.py # System utilities
|
||||||
|
│ │ ├── core/ # Core functionality
|
||||||
|
│ │ │ ├── config.py # Config management
|
||||||
|
│ │ │ ├── input_ctrl.py # Windows input
|
||||||
|
│ │ │ ├── screen.py # Screen capture
|
||||||
|
│ │ │ └── mdns.py # mDNS service
|
||||||
|
│ │ └── main.py # FastAPI app
|
||||||
|
│ └── pyproject.toml # Python dependencies
|
||||||
|
│
|
||||||
|
├── frontend/ # React TypeScript frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/ # Page components
|
||||||
|
│ │ ├── components/ # UI components
|
||||||
|
│ │ ├── api/ # API client
|
||||||
|
│ │ ├── stores/ # State management
|
||||||
|
│ │ └── App.tsx # Main app
|
||||||
|
│ └── package.json # Node dependencies
|
||||||
|
│
|
||||||
|
├── scripts/ # Development scripts
|
||||||
|
│ └── dev.ps1 # Windows dev script
|
||||||
|
│
|
||||||
|
└── PRD.md # Product requirements
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎮 Features
|
||||||
|
|
||||||
|
### Phase 1 (MVP) - Current
|
||||||
|
- ✅ Directional pad for aim control
|
||||||
|
- ✅ Club selection (up/down)
|
||||||
|
- ✅ Mulligan button
|
||||||
|
- ✅ Tee box navigation
|
||||||
|
- ✅ Map panel with streaming
|
||||||
|
- ✅ All GSPro keyboard shortcuts
|
||||||
|
- ✅ WebSocket-based streaming
|
||||||
|
- ✅ Configuration persistence
|
||||||
|
- ✅ mDNS service discovery
|
||||||
|
|
||||||
|
### Phase 2 (Planned)
|
||||||
|
- ⏳ OCR-based auto-detection
|
||||||
|
- ⏳ Visual marker tracking
|
||||||
|
- ⏳ Background monitoring
|
||||||
|
- ⏳ Advanced automation
|
||||||
|
|
||||||
|
### Phase 3 (Future)
|
||||||
|
- ⏳ Enhanced UI/UX
|
||||||
|
- ⏳ Subscription features
|
||||||
|
- ⏳ Extended documentation
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Backend Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 5005
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 API Documentation
|
||||||
|
|
||||||
|
The backend provides a comprehensive REST API with WebSocket support for streaming.
|
||||||
|
|
||||||
|
### Key Endpoints
|
||||||
|
|
||||||
|
- `POST /api/actions/key` - Send keyboard input to GSPro
|
||||||
|
- `WS /api/vision/ws/stream` - WebSocket for map streaming
|
||||||
|
- `GET /api/config` - Get current configuration
|
||||||
|
- `GET /api/system/health` - Health check
|
||||||
|
|
||||||
|
Full API documentation available at http://localhost:5005/api/docs when running.
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
Configuration is stored at `%LOCALAPPDATA%\GSPro Remote\config.json`
|
||||||
|
|
||||||
|
### Default Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 5005,
|
||||||
|
"mdns_enabled": true
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"fps": 30,
|
||||||
|
"quality": 85,
|
||||||
|
"resolution": "720p"
|
||||||
|
},
|
||||||
|
"gspro": {
|
||||||
|
"window_title": "GSPro",
|
||||||
|
"auto_focus": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### GSPro Window Not Found
|
||||||
|
- Ensure GSPro is running
|
||||||
|
- Check window title in configuration
|
||||||
|
- Run backend as administrator if needed
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
- Verify both frontend and backend are running
|
||||||
|
- Check Windows firewall settings
|
||||||
|
- Ensure devices are on same network
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
- Check if another instance is running
|
||||||
|
- Change port in configuration
|
||||||
|
- Use `netstat -an | findstr :5005` to check
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
MIT License - See LICENSE file for details
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- Create an issue on GitHub
|
||||||
|
- Check existing issues for solutions
|
||||||
191
Recommended kickoff plan.md
Normal file
191
Recommended kickoff plan.md
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
# Recommended kickoff plan
|
||||||
|
|
||||||
|
## 0) Toolchain (lock these down)
|
||||||
|
|
||||||
|
* **Python** 3.11 (for wheels availability + perf)
|
||||||
|
* **Node** 20.19+ and **npm** 10+
|
||||||
|
* **UV** for deps
|
||||||
|
* **Playwright** (later for UI smoke tests)
|
||||||
|
* **Git** repo from day 1
|
||||||
|
|
||||||
|
## 1) Make a tiny, opinionated repo scaffold
|
||||||
|
|
||||||
|
Create a new repo (you can copy/paste this tree into your README so the LLM follows it):
|
||||||
|
|
||||||
|
```
|
||||||
|
gspro-remote/
|
||||||
|
backend/
|
||||||
|
app/
|
||||||
|
__init__.py
|
||||||
|
main.py # FastAPI app factory + router mounts
|
||||||
|
api/
|
||||||
|
__init__.py
|
||||||
|
actions.py # /api/actions/*
|
||||||
|
vision.py # /api/vision/* (keep but V2-gated)
|
||||||
|
core/
|
||||||
|
config.py # AppConfig + load/save
|
||||||
|
input_ctrl.py # pydirectinput helpers
|
||||||
|
screen.py # mss capture utils (non-vision bits)
|
||||||
|
pkg.json # (optional) for uvicorn dev script via npm - or just use Makefile
|
||||||
|
pyproject.toml
|
||||||
|
README.md
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
main.tsx
|
||||||
|
App.tsx
|
||||||
|
pages/DynamicGolfUI.tsx
|
||||||
|
components/
|
||||||
|
AimPad.tsx
|
||||||
|
StatBar.tsx
|
||||||
|
MapPanel.tsx
|
||||||
|
styles/
|
||||||
|
index.html
|
||||||
|
package.json
|
||||||
|
vite.config.ts
|
||||||
|
README.md
|
||||||
|
scripts/
|
||||||
|
dev.ps1 # start both servers on Windows
|
||||||
|
.editorconfig
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 2) Bootstrap bare bones projects
|
||||||
|
|
||||||
|
### Backend (FastAPI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv venv && uv pip install fastapi uvicorn pydantic-settings pydirectinput pywin32 mss opencv-python pillow zeroconf
|
||||||
|
# Or with pip:
|
||||||
|
# python -m venv .venv && .venv\Scripts\activate && pip install fastapi uvicorn pydirectinput pywin32 mss opencv-python pillow zeroconf
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/main.py` (minimal)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from .api.actions import router as actions_router
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(title="GSPro Remote", version="0.1.0")
|
||||||
|
app.include_router(actions_router, prefix="/api/actions", tags=["actions"])
|
||||||
|
return app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/api/actions.py` (just enough to prove the vertical slice)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from ..core.input_ctrl import press_keys, focus_window
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
class KeyReq(BaseModel):
|
||||||
|
keys: str
|
||||||
|
|
||||||
|
@router.post("/key")
|
||||||
|
def post_key(req: KeyReq):
|
||||||
|
if not focus_window("GSPro"):
|
||||||
|
raise HTTPException(409, "GSPro window not found/active")
|
||||||
|
press_keys(req.keys)
|
||||||
|
return {"ok": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 5005
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (Vite + React + TS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../frontend
|
||||||
|
npm create vite@latest . -- --template react-ts
|
||||||
|
npm i
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `.env` in frontend later if you want a configurable API base URL.
|
||||||
|
|
||||||
|
## 3) Migrate your existing code **incrementally**
|
||||||
|
|
||||||
|
You already have solid pieces. Bring them in slice by slice:
|
||||||
|
|
||||||
|
* Move `config.py` → `backend/app/core/config.py` (keep AppConfig + persistence).
|
||||||
|
* Move `input_ctrl.py` → `backend/app/core/input_ctrl.py` (unchanged).
|
||||||
|
* Create `backend/app/api/vision.py` and paste **only** the streaming endpoint you’ll use first (WebSocket or SSE). Keep OCR endpoints behind a `VISION_ENABLED` flag for V2.
|
||||||
|
* Defer `screen_watch.py`, `capture.py`, `streaming.py` until the “MapPanel” slice (below). It’s okay if V1 ships with **no** OCR.
|
||||||
|
|
||||||
|
## 4) Implement one **vertical slice** end-to-end (MVP proof)
|
||||||
|
|
||||||
|
Start with the **Aim Pad + Mulligan** because it touches everything:
|
||||||
|
|
||||||
|
* Frontend:
|
||||||
|
|
||||||
|
* `components/AimPad.tsx`: buttons call `POST /api/actions/key` with `"left"`, `"right"`, `"up"`, `"down"`, and `"a"` (Reset).
|
||||||
|
* Add Mulligan button calling `"ctrl+m"`.
|
||||||
|
|
||||||
|
* Backend:
|
||||||
|
|
||||||
|
* The `post_key` route already exists.
|
||||||
|
* Make sure `focus_window("GSPro")` works on your machine.
|
||||||
|
|
||||||
|
* Test on Windows: you should see GSPro react from the tablet/phone.
|
||||||
|
|
||||||
|
Now you have a working remote!
|
||||||
|
|
||||||
|
## 5) Add the **MapPanel** as the second slice
|
||||||
|
|
||||||
|
* Backend:
|
||||||
|
|
||||||
|
* Introduce a simple `/api/vision/ws/stream` that returns a **downscaled JPEG** buffer of a fixed region; reuse your `mss` capture and JPEG base64 helpers. Keep the code minimal.
|
||||||
|
* Frontend:
|
||||||
|
|
||||||
|
* `components/MapPanel.tsx` opens a WS to `/api/vision/ws/stream`, paints frames onto a `<canvas>`, supports expand/collapse (no click mapping yet).
|
||||||
|
* Aim for **720p @ 30fps, \~75–85 JPEG quality**. Measure latency, then optimize resize **before** JPEG encode.
|
||||||
|
|
||||||
|
## 6) Wire the rest of the “Essentials”
|
||||||
|
|
||||||
|
* Tee left/right (`c`/`v`)
|
||||||
|
* Scorecard (`t`) and Range finder (`r`) as secondary buttons under a kebab menu
|
||||||
|
* Stat row at bottom (hard-coded 0.0° up/right for now—wire later)
|
||||||
|
|
||||||
|
## 7) Keep OCR as **V2** but leave hooks
|
||||||
|
|
||||||
|
* Add a feature flag `VISION_ENABLED = False` in `app/core/config.py`.
|
||||||
|
* Keep `vision.py` imported but routes gated: if disabled, return 404 with a friendly message.
|
||||||
|
* This lets you merge OCR work later without reshaping the app.
|
||||||
|
|
||||||
|
## 8) Dev ergonomics on Windows
|
||||||
|
|
||||||
|
Create `scripts/dev.ps1` to run both servers:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Start-Job { Set-Location backend; uvicorn app.main:app --reload --port 5005 }
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
Start-Process powershell -ArgumentList 'cd frontend; npm run dev'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9) Packaging (when MVP is stable)
|
||||||
|
|
||||||
|
* **Backend**: PyInstaller or Nuitka into a single EXE, then wrap with **Inno Setup** to produce an installer that:
|
||||||
|
|
||||||
|
* Installs the EXE + config files to `C:\ProgramData\GSPro Remote\`
|
||||||
|
* Creates a Start Menu entry and an **auto-start shortcut** for the Windows user
|
||||||
|
* Opens firewall rule for your port
|
||||||
|
* **Frontend**: `npm run build` → copy `dist/` into `backend/ui/` and serve using FastAPI `StaticFiles`. (You already had `build-ui.py`; keep that idea.)
|
||||||
|
|
||||||
|
## 10) How to use the LLM effectively (so it helps, not hurts)
|
||||||
|
|
||||||
|
* **Give it the scaffold** and the **PRD** (and the Section-12 matrix).
|
||||||
|
* Ask for **one file at a time**: “Implement `components/AimPad.tsx` against `/api/actions/key`” with explicit props and return types.
|
||||||
|
* Paste back **real compiler/server errors** verbatim; ask it to fix **only those**.
|
||||||
|
* Freeze the public contracts early (API request/response shapes). LLMs drift if the interface is fuzzy.
|
||||||
|
|
||||||
|
---
|
||||||
259
SETUP.md
Normal file
259
SETUP.md
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
# GSPro Remote - Setup & Run Instructions
|
||||||
|
|
||||||
|
## 🚀 Quick Setup Guide
|
||||||
|
|
||||||
|
Follow these steps to get GSPro Remote running on your network and accessible from your phone/tablet.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Windows PC** with GSPro installed
|
||||||
|
2. **Python 3.11+** installed ([Download Python](https://www.python.org/downloads/))
|
||||||
|
3. **Node.js 20+** installed ([Download Node.js](https://nodejs.org/))
|
||||||
|
4. **Git** (optional, for cloning the repository)
|
||||||
|
|
||||||
|
## Step 1: Initial Setup
|
||||||
|
|
||||||
|
### Option A: Using PowerShell Script (Recommended)
|
||||||
|
|
||||||
|
1. Open PowerShell as Administrator
|
||||||
|
2. Navigate to the project directory:
|
||||||
|
```powershell
|
||||||
|
cd "path\to\GSPro Remote"
|
||||||
|
```
|
||||||
|
3. Run the development script:
|
||||||
|
```powershell
|
||||||
|
.\scripts\dev.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
- Check all prerequisites
|
||||||
|
- Install Python dependencies
|
||||||
|
- Install Node.js dependencies
|
||||||
|
- Start both backend and frontend servers
|
||||||
|
- Open browser windows with the application
|
||||||
|
|
||||||
|
### Option B: Manual Setup
|
||||||
|
|
||||||
|
#### Backend Setup:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend Setup:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Running the Application
|
||||||
|
|
||||||
|
### Start Backend Server:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
.venv\Scripts\activate
|
||||||
|
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 5005
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend will be available at:
|
||||||
|
- API: `http://localhost:5005`
|
||||||
|
- API Docs: `http://localhost:5005/api/docs`
|
||||||
|
|
||||||
|
### Start Frontend Server:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev -- --host
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will be available at:
|
||||||
|
- Local: `http://localhost:5173`
|
||||||
|
- Network: `http://[YOUR-IP]:5173`
|
||||||
|
|
||||||
|
## Step 3: Access from Your Phone/Tablet
|
||||||
|
|
||||||
|
### Find Your PC's IP Address
|
||||||
|
|
||||||
|
1. Open Command Prompt or PowerShell
|
||||||
|
2. Run: `ipconfig`
|
||||||
|
3. Look for your IPv4 Address (e.g., `192.168.1.100`)
|
||||||
|
|
||||||
|
### Connect from Mobile Device
|
||||||
|
|
||||||
|
1. **Ensure your phone/tablet is on the same WiFi network as your PC**
|
||||||
|
|
||||||
|
2. Open a web browser on your mobile device
|
||||||
|
|
||||||
|
3. Navigate to one of these URLs:
|
||||||
|
- `http://[YOUR-PC-IP]:5173` (e.g., `http://192.168.1.100:5173`)
|
||||||
|
- `http://gsproapp.local:5005/ui` (if mDNS is working)
|
||||||
|
|
||||||
|
### Install as Mobile App (PWA)
|
||||||
|
|
||||||
|
**On iPhone/iPad:**
|
||||||
|
1. Open Safari (must be Safari)
|
||||||
|
2. Navigate to the app URL
|
||||||
|
3. Tap the Share button
|
||||||
|
4. Scroll down and tap "Add to Home Screen"
|
||||||
|
5. Name it "GSPro Remote" and tap Add
|
||||||
|
|
||||||
|
**On Android:**
|
||||||
|
1. Open Chrome
|
||||||
|
2. Navigate to the app URL
|
||||||
|
3. Tap the menu (3 dots)
|
||||||
|
4. Tap "Add to Home screen" or "Install app"
|
||||||
|
5. Follow the prompts
|
||||||
|
|
||||||
|
## Step 4: Configure Windows Firewall
|
||||||
|
|
||||||
|
If you can't connect from your mobile device, you may need to allow the app through Windows Firewall:
|
||||||
|
|
||||||
|
1. Open Windows Defender Firewall
|
||||||
|
2. Click "Allow an app or feature through Windows Defender Firewall"
|
||||||
|
3. Click "Change settings" then "Allow another app..."
|
||||||
|
4. Browse and add:
|
||||||
|
- Python.exe (from your Python installation)
|
||||||
|
- Node.exe (from your Node.js installation)
|
||||||
|
5. Check both Private and Public networks
|
||||||
|
6. Click OK
|
||||||
|
|
||||||
|
### Alternative: Create Firewall Rules via PowerShell (Admin)
|
||||||
|
```powershell
|
||||||
|
# Allow backend port
|
||||||
|
New-NetFirewallRule -DisplayName "GSPro Remote Backend" -Direction Inbound -Protocol TCP -LocalPort 5005 -Action Allow
|
||||||
|
|
||||||
|
# Allow frontend port
|
||||||
|
New-NetFirewallRule -DisplayName "GSPro Remote Frontend" -Direction Inbound -Protocol TCP -LocalPort 5173 -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Start Using GSPro Remote
|
||||||
|
|
||||||
|
1. **Start GSPro** on your PC
|
||||||
|
2. **Open GSPro Remote** on your mobile device
|
||||||
|
3. The app should show "Connected" status
|
||||||
|
4. You can now control GSPro from your mobile device!
|
||||||
|
|
||||||
|
## 🎮 Using the App
|
||||||
|
|
||||||
|
### Main Controls:
|
||||||
|
- **Aim Pad**: Hold arrows to adjust aim continuously
|
||||||
|
- **Center Button**: Press to reset aim
|
||||||
|
- **Club Selection**: Tap up/down to change clubs
|
||||||
|
- **Mulligan**: Toggle mulligan mode
|
||||||
|
- **Tee Box**: Move left/right on the tee
|
||||||
|
- **Map**: Start streaming to see live mini-map
|
||||||
|
- **Shot Options**: Select different shot types
|
||||||
|
- **Quick Actions** (+ button): Access additional controls
|
||||||
|
|
||||||
|
### Gestures:
|
||||||
|
- **Hold** directional buttons for continuous movement
|
||||||
|
- **Tap** for single adjustments
|
||||||
|
- **Expand Map**: Tap expand button for fullscreen map
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Can't connect from phone
|
||||||
|
- ✅ Check both devices are on the same WiFi network
|
||||||
|
- ✅ Check Windows Firewall settings
|
||||||
|
- ✅ Try using IP address instead of hostname
|
||||||
|
- ✅ Disable VPN if active
|
||||||
|
- ✅ Make sure backend shows "Server running on 0.0.0.0:5005"
|
||||||
|
|
||||||
|
### GSPro not responding to commands
|
||||||
|
- ✅ Ensure GSPro is running and focused
|
||||||
|
- ✅ Check GSPro window title matches config (default: "GSPro")
|
||||||
|
- ✅ Try running backend as Administrator
|
||||||
|
- ✅ Check backend console for errors
|
||||||
|
|
||||||
|
### Map streaming not working
|
||||||
|
- ✅ Click "Start Streaming" button in map panel
|
||||||
|
- ✅ Check backend console for WebSocket connection
|
||||||
|
- ✅ Verify GSPro is on the main game screen
|
||||||
|
- ✅ Adjust map region in settings if needed
|
||||||
|
|
||||||
|
### Poor performance on mobile
|
||||||
|
- ✅ Reduce stream quality in settings
|
||||||
|
- ✅ Lower FPS to 15-20
|
||||||
|
- ✅ Use 5GHz WiFi if available
|
||||||
|
- ✅ Move closer to WiFi router
|
||||||
|
|
||||||
|
## 📱 Mobile-Specific Tips
|
||||||
|
|
||||||
|
### For Best Experience:
|
||||||
|
1. **Lock screen orientation** to prevent rotation while playing
|
||||||
|
2. **Enable "Add to Home Screen"** for full-screen experience
|
||||||
|
3. **Disable browser gestures** if they interfere
|
||||||
|
4. **Use landscape mode** on tablets for optimal layout
|
||||||
|
5. **Keep screen awake** during gameplay
|
||||||
|
|
||||||
|
### Battery Optimization:
|
||||||
|
- Lower stream quality to "Low" or "Medium"
|
||||||
|
- Reduce FPS to 15-20
|
||||||
|
- Disable haptic feedback if not needed
|
||||||
|
- Dim screen brightness when possible
|
||||||
|
|
||||||
|
## 🔒 Security Notes
|
||||||
|
|
||||||
|
- The app only works on your local network
|
||||||
|
- No data is sent to external servers
|
||||||
|
- All communication is between your PC and mobile device
|
||||||
|
- Consider using a dedicated WiFi network for gaming
|
||||||
|
|
||||||
|
## 🆘 Getting Help
|
||||||
|
|
||||||
|
### Check Status:
|
||||||
|
1. Backend health: `http://localhost:5005/api/system/health`
|
||||||
|
2. API docs: `http://localhost:5005/api/docs`
|
||||||
|
3. System info: `http://localhost:5005/api/system/info`
|
||||||
|
4. Diagnostics: `http://localhost:5005/api/system/diagnostics`
|
||||||
|
|
||||||
|
### View Logs:
|
||||||
|
- Backend logs appear in the terminal where you started the server
|
||||||
|
- Frontend logs appear in the browser console (F12)
|
||||||
|
|
||||||
|
### Common Commands:
|
||||||
|
```bash
|
||||||
|
# Check if ports are in use
|
||||||
|
netstat -an | findstr :5005
|
||||||
|
netstat -an | findstr :5173
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
python --version
|
||||||
|
|
||||||
|
# Check Node version
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# Check network interfaces
|
||||||
|
ipconfig /all
|
||||||
|
|
||||||
|
# Test backend directly
|
||||||
|
curl http://localhost:5005/api/system/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Production Deployment
|
||||||
|
|
||||||
|
For permanent installation:
|
||||||
|
|
||||||
|
1. **Build the frontend**:
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure backend to serve UI**:
|
||||||
|
- Frontend will be built to `backend/ui`
|
||||||
|
- Access complete app at `http://gsproapp.local:5005`
|
||||||
|
|
||||||
|
3. **Create Windows Service** (optional):
|
||||||
|
- Use NSSM or similar to run backend as service
|
||||||
|
- Auto-starts with Windows
|
||||||
|
- Runs in background
|
||||||
|
|
||||||
|
4. **Configure auto-start**:
|
||||||
|
- Add to Windows startup folder
|
||||||
|
- Or use Task Scheduler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Enjoy controlling GSPro from anywhere in your simulator room!** 🏌️♂️
|
||||||
167
backend/README.md
Normal file
167
backend/README.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# GSPro Remote Backend
|
||||||
|
|
||||||
|
FastAPI-based backend service for GSPro Remote, providing keyboard control and screen streaming capabilities for GSPro golf simulator.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Keyboard Control API**: Send keyboard shortcuts to GSPro
|
||||||
|
- **Screen Streaming**: WebSocket-based map region streaming
|
||||||
|
- **Configuration Management**: Persistent settings storage
|
||||||
|
- **mDNS Discovery**: Auto-discoverable at `gsproapp.local`
|
||||||
|
- **Windows Integration**: Native Windows input simulation
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- Windows OS (for GSPro integration)
|
||||||
|
- GSPro running on the same machine
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Using pip
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using UV (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv
|
||||||
|
uv pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Install development dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the development server:
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 5005
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/actions/
|
||||||
|
POST /key - Send single key press
|
||||||
|
POST /keydown - Hold key down
|
||||||
|
POST /keyup - Release key
|
||||||
|
POST /combo - Send key combination
|
||||||
|
|
||||||
|
/api/config/
|
||||||
|
GET / - Get current configuration
|
||||||
|
PUT / - Update configuration
|
||||||
|
POST /save - Persist to disk
|
||||||
|
|
||||||
|
/api/vision/
|
||||||
|
WS /ws/stream - WebSocket map streaming
|
||||||
|
GET /regions - Get defined screen regions
|
||||||
|
POST /capture - Capture screen region
|
||||||
|
|
||||||
|
/api/system/
|
||||||
|
GET /health - Health check
|
||||||
|
GET /info - System information
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is stored in `%LOCALAPPDATA%\GSPro Remote\config.json`
|
||||||
|
|
||||||
|
Default configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 5005,
|
||||||
|
"mdns_enabled": true
|
||||||
|
},
|
||||||
|
"capture": {
|
||||||
|
"fps": 30,
|
||||||
|
"quality": 85,
|
||||||
|
"resolution": "720p"
|
||||||
|
},
|
||||||
|
"gspro": {
|
||||||
|
"window_title": "GSPro",
|
||||||
|
"auto_focus": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
app/
|
||||||
|
__init__.py
|
||||||
|
main.py # FastAPI application
|
||||||
|
api/
|
||||||
|
actions.py # Keyboard control endpoints
|
||||||
|
config.py # Configuration endpoints
|
||||||
|
vision.py # Screen capture/streaming
|
||||||
|
system.py # System utilities
|
||||||
|
core/
|
||||||
|
config.py # Configuration management
|
||||||
|
input_ctrl.py # Windows input simulation
|
||||||
|
screen.py # Screen capture utilities
|
||||||
|
mdns.py # mDNS service registration
|
||||||
|
models/
|
||||||
|
requests.py # Pydantic request models
|
||||||
|
responses.py # Pydantic response models
|
||||||
|
tests/
|
||||||
|
test_*.py # Unit tests
|
||||||
|
pyproject.toml # Project dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests with pytest:
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
With coverage:
|
||||||
|
```bash
|
||||||
|
pytest --cov=app --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building for Distribution
|
||||||
|
|
||||||
|
Build standalone executable:
|
||||||
|
```bash
|
||||||
|
pip install ".[build]"
|
||||||
|
python -m PyInstaller --onefile --name gspro-remote app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `GSPRO_REMOTE_PORT`: Override default port (5005)
|
||||||
|
- `GSPRO_REMOTE_HOST`: Override default host (0.0.0.0)
|
||||||
|
- `GSPRO_REMOTE_CONFIG_PATH`: Override config location
|
||||||
|
- `GSPRO_REMOTE_DEBUG`: Enable debug logging
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### GSPro window not found
|
||||||
|
- Ensure GSPro is running
|
||||||
|
- Check window title matches configuration
|
||||||
|
- Run as administrator if permission issues
|
||||||
|
|
||||||
|
### Port already in use
|
||||||
|
- Check if another instance is running
|
||||||
|
- Change port in configuration
|
||||||
|
- Use `netstat -an | findstr :5005` to check
|
||||||
|
|
||||||
|
### mDNS not working
|
||||||
|
- Check Windows firewall settings
|
||||||
|
- Ensure Bonjour service is running
|
||||||
|
- Try accessing directly via IP instead
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - See parent LICENSE file for details
|
||||||
13
backend/app/__init__.py
Normal file
13
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""
|
||||||
|
GSPro Remote Backend Application
|
||||||
|
|
||||||
|
A FastAPI-based backend service for controlling GSPro golf simulator
|
||||||
|
via keyboard shortcuts and providing screen streaming capabilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__author__ = "GSPro Remote Team"
|
||||||
|
|
||||||
|
from .main import app
|
||||||
|
|
||||||
|
__all__ = ["app", "__version__"]
|
||||||
7
backend/app/api/__init__.py
Normal file
7
backend/app/api/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
API module for GSPro Remote backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import actions, config, system, vision
|
||||||
|
|
||||||
|
__all__ = ["actions", "config", "system", "vision"]
|
||||||
232
backend/app/api/actions.py
Normal file
232
backend/app/api/actions.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"""
|
||||||
|
Actions API for sending keyboard inputs to GSPro.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..core.input_ctrl import press_key, press_keys, key_down, key_up, focus_window, is_gspro_running
|
||||||
|
from ..core.config import get_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class KeyPressRequest(BaseModel):
|
||||||
|
"""Request model for single key press."""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Key or key combination to press (e.g., 'a', 'ctrl+m')")
|
||||||
|
delay: Optional[float] = Field(0.0, description="Delay in seconds before pressing the key")
|
||||||
|
|
||||||
|
|
||||||
|
class KeyHoldRequest(BaseModel):
|
||||||
|
"""Request model for key hold operations."""
|
||||||
|
|
||||||
|
key: str = Field(..., description="Key to hold or release")
|
||||||
|
duration: Optional[float] = Field(None, description="Duration to hold the key (seconds)")
|
||||||
|
|
||||||
|
|
||||||
|
class KeySequenceRequest(BaseModel):
|
||||||
|
"""Request model for key sequence."""
|
||||||
|
|
||||||
|
keys: list[str] = Field(..., description="List of keys to press in sequence")
|
||||||
|
interval: float = Field(0.1, description="Interval between key presses (seconds)")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionResponse(BaseModel):
|
||||||
|
"""Response model for action endpoints."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/key", response_model=ActionResponse)
|
||||||
|
async def send_key(request: KeyPressRequest):
|
||||||
|
"""
|
||||||
|
Send a single key press or key combination to GSPro.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Single keys: 'a', 'space', 'enter'
|
||||||
|
- Combinations: 'ctrl+m', 'shift+tab'
|
||||||
|
- Function keys: 'f1', 'f11'
|
||||||
|
- Arrow keys: 'up', 'down', 'left', 'right'
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Check if GSPro is running
|
||||||
|
if not is_gspro_running():
|
||||||
|
raise HTTPException(status_code=409, detail="GSPro is not running or window not found")
|
||||||
|
|
||||||
|
# Focus GSPro window if auto-focus is enabled
|
||||||
|
if config.gspro.auto_focus:
|
||||||
|
if not focus_window(config.gspro.window_title):
|
||||||
|
logger.warning(f"Could not focus window: {config.gspro.window_title}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Apply delay if specified
|
||||||
|
if request.delay > 0:
|
||||||
|
await asyncio.sleep(request.delay)
|
||||||
|
|
||||||
|
# Send the key press
|
||||||
|
if "+" in request.key:
|
||||||
|
# Handle key combination
|
||||||
|
press_keys(request.key)
|
||||||
|
else:
|
||||||
|
# Handle single key
|
||||||
|
press_key(request.key)
|
||||||
|
|
||||||
|
logger.info(f"Sent key press: {request.key}")
|
||||||
|
return ActionResponse(success=True, message=f"Key '{request.key}' pressed successfully", key=request.key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send key press: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to send key press: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keydown", response_model=ActionResponse)
|
||||||
|
async def send_key_down(request: KeyHoldRequest, background_tasks: BackgroundTasks):
|
||||||
|
"""
|
||||||
|
Press and hold a key down.
|
||||||
|
|
||||||
|
If duration is specified, the key will be automatically released after that time.
|
||||||
|
Otherwise, you must call /keyup to release it.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if not is_gspro_running():
|
||||||
|
raise HTTPException(status_code=409, detail="GSPro is not running or window not found")
|
||||||
|
|
||||||
|
if config.gspro.auto_focus:
|
||||||
|
focus_window(config.gspro.window_title)
|
||||||
|
|
||||||
|
try:
|
||||||
|
key_down(request.key)
|
||||||
|
logger.info(f"Key down: {request.key}")
|
||||||
|
|
||||||
|
# If duration is specified, schedule key release
|
||||||
|
if request.duration:
|
||||||
|
|
||||||
|
async def release_key():
|
||||||
|
await asyncio.sleep(request.duration)
|
||||||
|
key_up(request.key)
|
||||||
|
logger.info(f"Key released after {request.duration}s: {request.key}")
|
||||||
|
|
||||||
|
background_tasks.add_task(release_key)
|
||||||
|
|
||||||
|
return ActionResponse(
|
||||||
|
success=True, message=f"Key '{request.key}' held for {request.duration}s", key=request.key
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return ActionResponse(
|
||||||
|
success=True, message=f"Key '{request.key}' pressed down (call /keyup to release)", key=request.key
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to hold key down: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to hold key down: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keyup", response_model=ActionResponse)
|
||||||
|
async def send_key_up(request: KeyHoldRequest):
|
||||||
|
"""
|
||||||
|
Release a held key.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if not is_gspro_running():
|
||||||
|
raise HTTPException(status_code=409, detail="GSPro is not running or window not found")
|
||||||
|
|
||||||
|
if config.gspro.auto_focus:
|
||||||
|
focus_window(config.gspro.window_title)
|
||||||
|
|
||||||
|
try:
|
||||||
|
key_up(request.key)
|
||||||
|
logger.info(f"Key up: {request.key}")
|
||||||
|
|
||||||
|
return ActionResponse(success=True, message=f"Key '{request.key}' released", key=request.key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to release key: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to release key: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sequence", response_model=ActionResponse)
|
||||||
|
async def send_key_sequence(request: KeySequenceRequest):
|
||||||
|
"""
|
||||||
|
Send a sequence of key presses with specified interval between them.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if not is_gspro_running():
|
||||||
|
raise HTTPException(status_code=409, detail="GSPro is not running or window not found")
|
||||||
|
|
||||||
|
if config.gspro.auto_focus:
|
||||||
|
focus_window(config.gspro.window_title)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for i, key in enumerate(request.keys):
|
||||||
|
if "+" in key:
|
||||||
|
press_keys(key)
|
||||||
|
else:
|
||||||
|
press_key(key)
|
||||||
|
|
||||||
|
# Add interval between keys (except after last one)
|
||||||
|
if i < len(request.keys) - 1:
|
||||||
|
await asyncio.sleep(request.interval)
|
||||||
|
|
||||||
|
logger.info(f"Sent key sequence: {request.keys}")
|
||||||
|
return ActionResponse(
|
||||||
|
success=True, message=f"Sent {len(request.keys)} key presses", key=", ".join(request.keys)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send key sequence: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to send key sequence: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shortcuts")
|
||||||
|
async def get_shortcuts():
|
||||||
|
"""
|
||||||
|
Get a list of all available GSPro keyboard shortcuts.
|
||||||
|
"""
|
||||||
|
shortcuts = {
|
||||||
|
"aim": {"up": "up", "down": "down", "left": "left", "right": "right", "reset": "a"},
|
||||||
|
"club": {"up": "u", "down": "k"},
|
||||||
|
"shot": {"mulligan": "ctrl+m", "options": "'", "putt_toggle": "u"},
|
||||||
|
"tee": {"left": "c", "right": "v"},
|
||||||
|
"view": {
|
||||||
|
"map_toggle": "s",
|
||||||
|
"map_zoom_in": "q",
|
||||||
|
"map_zoom_out": "w",
|
||||||
|
"scorecard": "t",
|
||||||
|
"range_finder": "r",
|
||||||
|
"heat_map": "y",
|
||||||
|
"pin_indicator": "p",
|
||||||
|
"flyover": "o",
|
||||||
|
"free_look": "f5",
|
||||||
|
"aim_point": "f3",
|
||||||
|
"green_grid": "g",
|
||||||
|
"ui_toggle": "h",
|
||||||
|
},
|
||||||
|
"camera": {"go_to_ball": "5", "fly_to_ball": "6", "shot_camera": "j"},
|
||||||
|
"practice": {"go_to_ball": "8", "previous_hole": "9", "next_hole": "0"},
|
||||||
|
"system": {
|
||||||
|
"fullscreen": "f11",
|
||||||
|
"fps_display": "f",
|
||||||
|
"console_short": "f8",
|
||||||
|
"console_tall": "f9",
|
||||||
|
"tracer_clear": "f1",
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"sound_on": "+",
|
||||||
|
"sound_off": "-",
|
||||||
|
"lighting": "l",
|
||||||
|
"3d_grass": "z",
|
||||||
|
"switch_hand": "n",
|
||||||
|
"shadow_increase": ">",
|
||||||
|
"shadow_decrease": "<",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"shortcuts": shortcuts, "total": sum(len(category) for category in shortcuts.values())}
|
||||||
348
backend/app/api/config.py
Normal file
348
backend/app/api/config.py
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
"""
|
||||||
|
Configuration API for managing GSPro Remote settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..core.config import get_config, reset_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigUpdateRequest(BaseModel):
|
||||||
|
"""Request model for configuration updates."""
|
||||||
|
|
||||||
|
server: Optional[Dict[str, Any]] = Field(None, description="Server configuration")
|
||||||
|
capture: Optional[Dict[str, Any]] = Field(None, description="Capture configuration")
|
||||||
|
gspro: Optional[Dict[str, Any]] = Field(None, description="GSPro configuration")
|
||||||
|
vision: Optional[Dict[str, Any]] = Field(None, description="Vision configuration")
|
||||||
|
debug: Optional[bool] = Field(None, description="Debug mode")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigResponse(BaseModel):
|
||||||
|
"""Response model for configuration endpoints."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureConfigUpdate(BaseModel):
|
||||||
|
"""Request model for capture configuration updates."""
|
||||||
|
|
||||||
|
fps: Optional[int] = Field(None, ge=1, le=60)
|
||||||
|
quality: Optional[int] = Field(None, ge=1, le=100)
|
||||||
|
resolution: Optional[str] = None
|
||||||
|
region_x: Optional[int] = Field(None, ge=0)
|
||||||
|
region_y: Optional[int] = Field(None, ge=0)
|
||||||
|
region_width: Optional[int] = Field(None, gt=0)
|
||||||
|
region_height: Optional[int] = Field(None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerConfigUpdate(BaseModel):
|
||||||
|
"""Request model for server configuration updates."""
|
||||||
|
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: Optional[int] = None
|
||||||
|
mdns_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GSProConfigUpdate(BaseModel):
|
||||||
|
"""Request model for GSPro configuration updates."""
|
||||||
|
|
||||||
|
window_title: Optional[str] = None
|
||||||
|
auto_focus: Optional[bool] = None
|
||||||
|
key_delay: Optional[float] = Field(None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
class VisionConfigUpdate(BaseModel):
|
||||||
|
"""Request model for vision configuration updates."""
|
||||||
|
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
ocr_engine: Optional[str] = None
|
||||||
|
confidence_threshold: Optional[float] = Field(None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=Dict[str, Any])
|
||||||
|
async def get_configuration():
|
||||||
|
"""
|
||||||
|
Get the current application configuration.
|
||||||
|
|
||||||
|
Returns all configuration sections including server, capture, gspro, and vision settings.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
return config.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/", response_model=ConfigResponse)
|
||||||
|
async def update_configuration(request: ConfigUpdateRequest):
|
||||||
|
"""
|
||||||
|
Update application configuration.
|
||||||
|
|
||||||
|
Only provided fields will be updated. Nested configuration objects are merged.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_dict = request.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if not update_dict:
|
||||||
|
return ConfigResponse(success=False, message="No configuration changes provided")
|
||||||
|
|
||||||
|
# Update configuration
|
||||||
|
config.update(**update_dict)
|
||||||
|
|
||||||
|
logger.info(f"Configuration updated: {list(update_dict.keys())}")
|
||||||
|
|
||||||
|
return ConfigResponse(success=True, message="Configuration updated successfully", config=config.to_dict())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update configuration: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update configuration: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/save", response_model=ConfigResponse)
|
||||||
|
async def save_configuration():
|
||||||
|
"""
|
||||||
|
Save the current configuration to disk.
|
||||||
|
|
||||||
|
This ensures changes persist across application restarts.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
return ConfigResponse(
|
||||||
|
success=True, message=f"Configuration saved to {config.config_path}", config=config.to_dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save configuration: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save configuration: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset", response_model=ConfigResponse)
|
||||||
|
async def reset_configuration():
|
||||||
|
"""
|
||||||
|
Reset configuration to default values.
|
||||||
|
|
||||||
|
This will overwrite any custom settings with the application defaults.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.reset()
|
||||||
|
|
||||||
|
logger.info("Configuration reset to defaults")
|
||||||
|
|
||||||
|
return ConfigResponse(success=True, message="Configuration reset to defaults", config=config.to_dict())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to reset configuration: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to reset configuration: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/server")
|
||||||
|
async def get_server_config():
|
||||||
|
"""Get server-specific configuration."""
|
||||||
|
config = get_config()
|
||||||
|
return config.server.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/server")
|
||||||
|
async def update_server_config(request: ServerConfigUpdate):
|
||||||
|
"""
|
||||||
|
Update server configuration.
|
||||||
|
|
||||||
|
Note: Changing host or port requires application restart to take effect.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
updates = request.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"success": False, "message": "No updates provided"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.update(server=updates)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Server configuration updated (restart required for host/port changes)",
|
||||||
|
"config": config.server.model_dump(),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update server config: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capture")
|
||||||
|
async def get_capture_config():
|
||||||
|
"""Get capture/streaming configuration."""
|
||||||
|
config = get_config()
|
||||||
|
return config.capture.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/capture")
|
||||||
|
async def update_capture_config(request: CaptureConfigUpdate):
|
||||||
|
"""Update capture/streaming configuration."""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
updates = request.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"success": False, "message": "No updates provided"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.update(capture=updates)
|
||||||
|
|
||||||
|
return {"success": True, "message": "Capture configuration updated", "config": config.capture.model_dump()}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update capture config: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gspro")
|
||||||
|
async def get_gspro_config():
|
||||||
|
"""Get GSPro-specific configuration."""
|
||||||
|
config = get_config()
|
||||||
|
return config.gspro.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/gspro")
|
||||||
|
async def update_gspro_config(request: GSProConfigUpdate):
|
||||||
|
"""Update GSPro-specific configuration."""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
updates = request.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"success": False, "message": "No updates provided"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.update(gspro=updates)
|
||||||
|
|
||||||
|
return {"success": True, "message": "GSPro configuration updated", "config": config.gspro.model_dump()}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update GSPro config: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vision")
|
||||||
|
async def get_vision_config():
|
||||||
|
"""Get vision/OCR configuration (V2 features)."""
|
||||||
|
config = get_config()
|
||||||
|
return {**config.vision.model_dump(), "note": "Vision features are planned for V2"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/vision")
|
||||||
|
async def update_vision_config(request: VisionConfigUpdate):
|
||||||
|
"""
|
||||||
|
Update vision/OCR configuration.
|
||||||
|
|
||||||
|
Note: These features are planned for V2 and not yet implemented.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
updates = request.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"success": False, "message": "No updates provided"}
|
||||||
|
|
||||||
|
# Validate OCR engine if provided
|
||||||
|
if "ocr_engine" in updates and updates["ocr_engine"] not in ["easyocr", "tesseract"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid OCR engine. Must be 'easyocr' or 'tesseract'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.update(vision=updates)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Vision configuration updated (V2 features)",
|
||||||
|
"config": config.vision.model_dump(),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update vision config: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reload", response_model=ConfigResponse)
|
||||||
|
async def reload_configuration():
|
||||||
|
"""
|
||||||
|
Reload configuration from disk.
|
||||||
|
|
||||||
|
This discards any unsaved changes and reloads from the configuration file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Reset the global config instance
|
||||||
|
reset_config()
|
||||||
|
|
||||||
|
# Get new config (will load from disk)
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
logger.info("Configuration reloaded from disk")
|
||||||
|
|
||||||
|
return ConfigResponse(
|
||||||
|
success=True, message=f"Configuration reloaded from {config.config_path}", config=config.to_dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to reload configuration: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to reload configuration: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/path")
|
||||||
|
async def get_config_path():
|
||||||
|
"""Get the configuration file path."""
|
||||||
|
config = get_config()
|
||||||
|
return {"path": str(config.config_path), "exists": config.config_path.exists() if config.config_path else False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/validate")
|
||||||
|
async def validate_configuration():
|
||||||
|
"""
|
||||||
|
Validate the current configuration.
|
||||||
|
|
||||||
|
Checks for common issues and required settings.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
# Check server configuration
|
||||||
|
if config.server.port < 1024:
|
||||||
|
warnings.append("Server port is below 1024, may require administrator privileges")
|
||||||
|
|
||||||
|
# Check capture configuration
|
||||||
|
if config.capture.fps > 30:
|
||||||
|
warnings.append("High FPS may impact performance")
|
||||||
|
|
||||||
|
if config.capture.quality < 50:
|
||||||
|
warnings.append("Low JPEG quality may result in poor image clarity")
|
||||||
|
|
||||||
|
# Check GSPro configuration
|
||||||
|
if not config.gspro.window_title:
|
||||||
|
issues.append("GSPro window title is not set")
|
||||||
|
|
||||||
|
# Check vision configuration
|
||||||
|
if config.vision.enabled:
|
||||||
|
warnings.append("Vision features are enabled but not yet implemented (V2)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(issues) == 0,
|
||||||
|
"issues": issues,
|
||||||
|
"warnings": warnings,
|
||||||
|
"summary": f"{len(issues)} issues, {len(warnings)} warnings",
|
||||||
|
}
|
||||||
409
backend/app/api/system.py
Normal file
409
backend/app/api/system.py
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
"""
|
||||||
|
System API for health checks, system information, and diagnostics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import platform
|
||||||
|
import psutil
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from ..core.config import get_config
|
||||||
|
from ..core.input_ctrl import is_gspro_running, get_gspro_process_info
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Track application start time
|
||||||
|
APP_START_TIME = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""
|
||||||
|
Health check endpoint for monitoring.
|
||||||
|
|
||||||
|
Returns basic health status and service availability.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Check GSPro status
|
||||||
|
gspro_running = is_gspro_running(config.gspro.window_title)
|
||||||
|
|
||||||
|
# Calculate uptime
|
||||||
|
uptime = datetime.now() - APP_START_TIME
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"uptime_seconds": int(uptime.total_seconds()),
|
||||||
|
"services": {
|
||||||
|
"api": "running",
|
||||||
|
"gspro": "connected" if gspro_running else "disconnected",
|
||||||
|
"mdns": "enabled" if config.server.mdns_enabled else "disabled",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info")
|
||||||
|
async def system_info():
|
||||||
|
"""
|
||||||
|
Get detailed system information.
|
||||||
|
|
||||||
|
Returns information about the host system, Python environment, and application.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Get system information
|
||||||
|
system_info = {
|
||||||
|
"platform": {
|
||||||
|
"system": platform.system(),
|
||||||
|
"release": platform.release(),
|
||||||
|
"version": platform.version(),
|
||||||
|
"machine": platform.machine(),
|
||||||
|
"processor": platform.processor(),
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"cpu_count": psutil.cpu_count(),
|
||||||
|
"cpu_percent": psutil.cpu_percent(interval=1),
|
||||||
|
"memory": {
|
||||||
|
"total": psutil.virtual_memory().total,
|
||||||
|
"available": psutil.virtual_memory().available,
|
||||||
|
"percent": psutil.virtual_memory().percent,
|
||||||
|
"used": psutil.virtual_memory().used,
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"total": psutil.disk_usage("/").total,
|
||||||
|
"used": psutil.disk_usage("/").used,
|
||||||
|
"free": psutil.disk_usage("/").free,
|
||||||
|
"percent": psutil.disk_usage("/").percent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"hostname": platform.node(),
|
||||||
|
"interfaces": _get_network_interfaces(),
|
||||||
|
},
|
||||||
|
"application": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"config_path": str(config.config_path),
|
||||||
|
"debug_mode": config.debug,
|
||||||
|
"uptime": str(datetime.now() - APP_START_TIME),
|
||||||
|
"server": {
|
||||||
|
"host": config.server.host,
|
||||||
|
"port": config.server.port,
|
||||||
|
"url": f"http://{config.server.host}:{config.server.port}",
|
||||||
|
"mdns_url": f"http://gsproapp.local:{config.server.port}" if config.server.mdns_enabled else None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return system_info
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gspro/status")
|
||||||
|
async def gspro_status():
|
||||||
|
"""
|
||||||
|
Get GSPro application status.
|
||||||
|
|
||||||
|
Returns information about the GSPro process if it's running.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
is_running = is_gspro_running(config.gspro.window_title)
|
||||||
|
process_info = get_gspro_process_info() if is_running else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"running": is_running,
|
||||||
|
"window_title": config.gspro.window_title,
|
||||||
|
"process": process_info,
|
||||||
|
"auto_focus": config.gspro.auto_focus,
|
||||||
|
"key_delay": config.gspro.key_delay,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gspro/find")
|
||||||
|
async def find_gspro_window():
|
||||||
|
"""
|
||||||
|
Search for GSPro windows.
|
||||||
|
|
||||||
|
Helps users identify the correct window title for configuration.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import win32gui
|
||||||
|
|
||||||
|
def enum_window_callback(hwnd, windows):
|
||||||
|
if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd):
|
||||||
|
window_text = win32gui.GetWindowText(hwnd)
|
||||||
|
if window_text and len(window_text) > 0:
|
||||||
|
# Look for windows that might be GSPro
|
||||||
|
if any(keyword in window_text.lower() for keyword in ["gspro", "golf", "simulator"]):
|
||||||
|
windows.append(
|
||||||
|
{"title": window_text, "hwnd": hwnd, "suggested": "gspro" in window_text.lower()}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
win32gui.EnumWindows(enum_window_callback, windows)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"found": len(windows) > 0,
|
||||||
|
"windows": windows,
|
||||||
|
"message": "Found potential GSPro windows" if windows else "No GSPro windows found",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to search for GSPro windows: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics")
|
||||||
|
async def get_metrics():
|
||||||
|
"""
|
||||||
|
Get application metrics and performance statistics.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Get current resource usage
|
||||||
|
process = psutil.Process()
|
||||||
|
|
||||||
|
metrics = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"uptime_seconds": int((datetime.now() - APP_START_TIME).total_seconds()),
|
||||||
|
"resources": {
|
||||||
|
"cpu_percent": process.cpu_percent(),
|
||||||
|
"memory_mb": process.memory_info().rss / 1024 / 1024,
|
||||||
|
"memory_percent": process.memory_percent(),
|
||||||
|
"threads": process.num_threads(),
|
||||||
|
"open_files": len(process.open_files()) if hasattr(process, "open_files") else 0,
|
||||||
|
"connections": len(process.connections()) if hasattr(process, "connections") else 0,
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"cpu_percent": psutil.cpu_percent(interval=0.5),
|
||||||
|
"memory_percent": psutil.virtual_memory().percent,
|
||||||
|
"disk_io": psutil.disk_io_counters()._asdict() if psutil.disk_io_counters() else {},
|
||||||
|
"network_io": psutil.net_io_counters()._asdict() if psutil.net_io_counters() else {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
async def get_logs(lines: int = 100):
|
||||||
|
"""
|
||||||
|
Get recent application logs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: Number of log lines to return (max 1000)
|
||||||
|
"""
|
||||||
|
# This is a placeholder - in production, you'd read from actual log files
|
||||||
|
return {
|
||||||
|
"message": "Log retrieval not yet implemented",
|
||||||
|
"lines_requested": min(lines, 1000),
|
||||||
|
"log_level": logging.getLevelName(logger.getEffectiveLevel()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restart")
|
||||||
|
async def restart_application():
|
||||||
|
"""
|
||||||
|
Restart the application.
|
||||||
|
|
||||||
|
Note: This endpoint requires proper process management setup.
|
||||||
|
"""
|
||||||
|
# This would typically signal the process manager to restart
|
||||||
|
# For now, just return a message
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Application restart requires process manager setup. Please restart manually.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dependencies")
|
||||||
|
async def check_dependencies():
|
||||||
|
"""
|
||||||
|
Check if all required dependencies are installed and accessible.
|
||||||
|
"""
|
||||||
|
dependencies = {
|
||||||
|
"required": {},
|
||||||
|
"optional": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check required dependencies
|
||||||
|
required_modules = [
|
||||||
|
"fastapi",
|
||||||
|
"uvicorn",
|
||||||
|
"pydantic",
|
||||||
|
"pydirectinput",
|
||||||
|
"mss",
|
||||||
|
"PIL",
|
||||||
|
"cv2",
|
||||||
|
"numpy",
|
||||||
|
"win32gui",
|
||||||
|
"psutil",
|
||||||
|
"zeroconf",
|
||||||
|
]
|
||||||
|
|
||||||
|
for module_name in required_modules:
|
||||||
|
try:
|
||||||
|
module = __import__(module_name)
|
||||||
|
version = getattr(module, "__version__", "unknown")
|
||||||
|
dependencies["required"][module_name] = {"installed": True, "version": version}
|
||||||
|
except ImportError:
|
||||||
|
dependencies["required"][module_name] = {"installed": False, "version": None}
|
||||||
|
|
||||||
|
# Check optional dependencies (V2 features)
|
||||||
|
optional_modules = [
|
||||||
|
"easyocr",
|
||||||
|
"pytesseract",
|
||||||
|
]
|
||||||
|
|
||||||
|
for module_name in optional_modules:
|
||||||
|
try:
|
||||||
|
module = __import__(module_name)
|
||||||
|
version = getattr(module, "__version__", "unknown")
|
||||||
|
dependencies["optional"][module_name] = {"installed": True, "version": version}
|
||||||
|
except ImportError:
|
||||||
|
dependencies["optional"][module_name] = {"installed": False, "version": None}
|
||||||
|
|
||||||
|
# Check if all required dependencies are installed
|
||||||
|
all_installed = all(dep["installed"] for dep in dependencies["required"].values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"all_required_installed": all_installed,
|
||||||
|
"dependencies": dependencies,
|
||||||
|
"message": "All required dependencies installed" if all_installed else "Some required dependencies are missing",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostics")
|
||||||
|
async def run_diagnostics():
|
||||||
|
"""
|
||||||
|
Run comprehensive system diagnostics.
|
||||||
|
|
||||||
|
Checks various aspects of the system and application configuration.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
diagnostics = {"timestamp": datetime.now().isoformat(), "checks": []}
|
||||||
|
|
||||||
|
# Check 1: GSPro connectivity
|
||||||
|
gspro_running = is_gspro_running(config.gspro.window_title)
|
||||||
|
diagnostics["checks"].append(
|
||||||
|
{
|
||||||
|
"name": "GSPro Connectivity",
|
||||||
|
"status": "pass" if gspro_running else "fail",
|
||||||
|
"message": "GSPro is running and accessible" if gspro_running else "GSPro window not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check 2: Network accessibility
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
|
||||||
|
socket.create_connection(("8.8.8.8", 53), timeout=3)
|
||||||
|
network_ok = True
|
||||||
|
network_msg = "Network connection is working"
|
||||||
|
except:
|
||||||
|
network_ok = False
|
||||||
|
network_msg = "No internet connection detected"
|
||||||
|
|
||||||
|
diagnostics["checks"].append(
|
||||||
|
{
|
||||||
|
"name": "Network Connectivity",
|
||||||
|
"status": "pass" if network_ok else "warning",
|
||||||
|
"message": network_msg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check 3: Configuration validity
|
||||||
|
config_valid = config.config_path and config.config_path.exists()
|
||||||
|
diagnostics["checks"].append(
|
||||||
|
{
|
||||||
|
"name": "Configuration File",
|
||||||
|
"status": "pass" if config_valid else "warning",
|
||||||
|
"message": f"Configuration file exists at {config.config_path}"
|
||||||
|
if config_valid
|
||||||
|
else "Configuration file not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check 4: Available disk space
|
||||||
|
disk_usage = psutil.disk_usage("/")
|
||||||
|
disk_ok = disk_usage.percent < 90
|
||||||
|
diagnostics["checks"].append(
|
||||||
|
{
|
||||||
|
"name": "Disk Space",
|
||||||
|
"status": "pass" if disk_ok else "warning",
|
||||||
|
"message": f"Disk usage at {disk_usage.percent:.1f}%"
|
||||||
|
+ (" - Consider freeing space" if not disk_ok else ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check 5: Memory availability
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
memory_ok = memory.percent < 90
|
||||||
|
diagnostics["checks"].append(
|
||||||
|
{
|
||||||
|
"name": "Memory Availability",
|
||||||
|
"status": "pass" if memory_ok else "warning",
|
||||||
|
"message": f"Memory usage at {memory.percent:.1f}%"
|
||||||
|
+ (" - High memory usage detected" if not memory_ok else ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check 6: Python version
|
||||||
|
import sys
|
||||||
|
|
||||||
|
python_ok = sys.version_info >= (3, 11)
|
||||||
|
diagnostics["checks"].append(
|
||||||
|
{
|
||||||
|
"name": "Python Version",
|
||||||
|
"status": "pass" if python_ok else "warning",
|
||||||
|
"message": f"Python {platform.python_version()}"
|
||||||
|
+ (" - Consider upgrading to 3.11+" if not python_ok else ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate overall status
|
||||||
|
statuses = [check["status"] for check in diagnostics["checks"]]
|
||||||
|
if "fail" in statuses:
|
||||||
|
overall = "fail"
|
||||||
|
elif "warning" in statuses:
|
||||||
|
overall = "warning"
|
||||||
|
else:
|
||||||
|
overall = "pass"
|
||||||
|
|
||||||
|
diagnostics["overall_status"] = overall
|
||||||
|
diagnostics["summary"] = {
|
||||||
|
"passed": sum(1 for s in statuses if s == "pass"),
|
||||||
|
"warnings": sum(1 for s in statuses if s == "warning"),
|
||||||
|
"failures": sum(1 for s in statuses if s == "fail"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _get_network_interfaces():
|
||||||
|
"""Helper function to get network interface information."""
|
||||||
|
interfaces = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for interface, addrs in psutil.net_if_addrs().items():
|
||||||
|
for addr in addrs:
|
||||||
|
if addr.family == socket.AF_INET: # IPv4
|
||||||
|
interfaces.append(
|
||||||
|
{
|
||||||
|
"name": interface,
|
||||||
|
"address": addr.address,
|
||||||
|
"netmask": addr.netmask,
|
||||||
|
"broadcast": addr.broadcast,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get network interfaces: {e}")
|
||||||
|
|
||||||
|
return interfaces
|
||||||
345
backend/app/api/vision.py
Normal file
345
backend/app/api/vision.py
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
"""
|
||||||
|
Vision API for screen capture and streaming.
|
||||||
|
Currently implements WebSocket streaming for the map panel.
|
||||||
|
OCR and advanced vision features are gated behind configuration flags for V2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..core.config import get_config
|
||||||
|
from ..core.screen import get_screen_capture, capture_region
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class StreamConfig(BaseModel):
|
||||||
|
"""Configuration for video streaming."""
|
||||||
|
|
||||||
|
fps: int = Field(30, ge=1, le=60, description="Frames per second")
|
||||||
|
quality: int = Field(85, ge=1, le=100, description="JPEG quality")
|
||||||
|
resolution: str = Field("720p", description="Stream resolution preset")
|
||||||
|
region_x: int = Field(0, ge=0, description="Region X coordinate")
|
||||||
|
region_y: int = Field(0, ge=0, description="Region Y coordinate")
|
||||||
|
region_width: int = Field(640, ge=1, description="Region width")
|
||||||
|
region_height: int = Field(480, ge=1, description="Region height")
|
||||||
|
|
||||||
|
|
||||||
|
class RegionDefinition(BaseModel):
|
||||||
|
"""Definition of a screen region for capture."""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Region name")
|
||||||
|
x: int = Field(..., ge=0, description="X coordinate")
|
||||||
|
y: int = Field(..., ge=0, description="Y coordinate")
|
||||||
|
width: int = Field(..., gt=0, description="Width")
|
||||||
|
height: int = Field(..., gt=0, description="Height")
|
||||||
|
description: Optional[str] = Field(None, description="Region description")
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureRequest(BaseModel):
|
||||||
|
"""Request to capture a screen region."""
|
||||||
|
|
||||||
|
x: int = Field(0, ge=0, description="X coordinate")
|
||||||
|
y: int = Field(0, ge=0, description="Y coordinate")
|
||||||
|
width: int = Field(640, gt=0, description="Width")
|
||||||
|
height: int = Field(480, gt=0, description="Height")
|
||||||
|
format: str = Field("base64", description="Output format (base64 or raw)")
|
||||||
|
quality: int = Field(85, ge=1, le=100, description="JPEG quality")
|
||||||
|
|
||||||
|
|
||||||
|
class StreamManager:
|
||||||
|
"""Manages WebSocket streaming sessions."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.active_streams: Dict[str, WebSocket] = {}
|
||||||
|
self.stream_configs: Dict[str, StreamConfig] = {}
|
||||||
|
|
||||||
|
async def add_stream(self, client_id: str, websocket: WebSocket, config: StreamConfig):
|
||||||
|
"""Add a new streaming session."""
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_streams[client_id] = websocket
|
||||||
|
self.stream_configs[client_id] = config
|
||||||
|
logger.info(f"Stream started for client {client_id}")
|
||||||
|
|
||||||
|
async def remove_stream(self, client_id: str):
|
||||||
|
"""Remove a streaming session."""
|
||||||
|
if client_id in self.active_streams:
|
||||||
|
del self.active_streams[client_id]
|
||||||
|
del self.stream_configs[client_id]
|
||||||
|
logger.info(f"Stream stopped for client {client_id}")
|
||||||
|
|
||||||
|
async def stream_frame(self, client_id: str, frame_data: str):
|
||||||
|
"""Send a frame to a specific client."""
|
||||||
|
if client_id in self.active_streams:
|
||||||
|
websocket = self.active_streams[client_id]
|
||||||
|
try:
|
||||||
|
await websocket.send_text(frame_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send frame to client {client_id}: {e}")
|
||||||
|
await self.remove_stream(client_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Global stream manager
|
||||||
|
stream_manager = StreamManager()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/stream")
|
||||||
|
async def stream_video(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for streaming video of a screen region.
|
||||||
|
|
||||||
|
The client should send a JSON message with stream configuration:
|
||||||
|
{
|
||||||
|
"action": "start",
|
||||||
|
"config": {
|
||||||
|
"fps": 30,
|
||||||
|
"quality": 85,
|
||||||
|
"resolution": "720p",
|
||||||
|
"region_x": 0,
|
||||||
|
"region_y": 0,
|
||||||
|
"region_width": 640,
|
||||||
|
"region_height": 480
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
client_id = id(websocket)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Accept the WebSocket connection
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info(f"WebSocket connected: client {client_id}")
|
||||||
|
|
||||||
|
# Wait for initial configuration message
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
message = json.loads(data)
|
||||||
|
|
||||||
|
if message.get("action") != "start":
|
||||||
|
await websocket.send_json({"error": "First message must be start action"})
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse stream configuration
|
||||||
|
stream_config = StreamConfig(**message.get("config", {}))
|
||||||
|
|
||||||
|
# Override with server config if needed
|
||||||
|
stream_config.fps = min(stream_config.fps, config.capture.fps)
|
||||||
|
stream_config.quality = config.capture.quality
|
||||||
|
|
||||||
|
# Add to stream manager
|
||||||
|
await stream_manager.add_stream(str(client_id), websocket, stream_config)
|
||||||
|
|
||||||
|
# Send confirmation
|
||||||
|
await websocket.send_json({"type": "config", "data": stream_config.model_dump()})
|
||||||
|
|
||||||
|
# Get screen capture instance
|
||||||
|
capture = get_screen_capture()
|
||||||
|
|
||||||
|
# Calculate frame interval
|
||||||
|
frame_interval = 1.0 / stream_config.fps
|
||||||
|
|
||||||
|
# Streaming loop
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Capture the region
|
||||||
|
image = capture.capture_region(
|
||||||
|
stream_config.region_x,
|
||||||
|
stream_config.region_y,
|
||||||
|
stream_config.region_width,
|
||||||
|
stream_config.region_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resize if needed
|
||||||
|
if stream_config.resolution != "native":
|
||||||
|
target_width, target_height = capture.get_resolution_preset(stream_config.resolution)
|
||||||
|
# Only resize if different from capture size
|
||||||
|
if target_width != stream_config.region_width or target_height != stream_config.region_height:
|
||||||
|
image = capture.resize_image(image, width=target_width)
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
base64_image = capture.image_to_base64(image, quality=stream_config.quality)
|
||||||
|
|
||||||
|
# Send frame
|
||||||
|
frame_data = json.dumps(
|
||||||
|
{"type": "frame", "data": base64_image, "timestamp": asyncio.get_event_loop().time()}
|
||||||
|
)
|
||||||
|
|
||||||
|
await websocket.send_text(frame_data)
|
||||||
|
|
||||||
|
# Wait for next frame
|
||||||
|
await asyncio.sleep(frame_interval)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"Client {client_id} disconnected")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in stream loop for client {client_id}: {e}")
|
||||||
|
await websocket.send_json({"type": "error", "message": str(e)})
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket error for client {client_id}: {e}")
|
||||||
|
finally:
|
||||||
|
await stream_manager.remove_stream(str(client_id))
|
||||||
|
logger.info(f"WebSocket closed: client {client_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/capture", response_model=Dict[str, Any])
|
||||||
|
async def capture_screen_region(request: CaptureRequest):
|
||||||
|
"""
|
||||||
|
Capture a single frame from a screen region.
|
||||||
|
|
||||||
|
This is useful for testing or getting a single snapshot.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
capture = get_screen_capture()
|
||||||
|
|
||||||
|
# Capture the region
|
||||||
|
image = capture.capture_region(request.x, request.y, request.width, request.height)
|
||||||
|
|
||||||
|
if request.format == "base64":
|
||||||
|
# Convert to base64
|
||||||
|
base64_image = capture.image_to_base64(image, quality=request.quality)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"format": "base64",
|
||||||
|
"data": base64_image,
|
||||||
|
"width": request.width,
|
||||||
|
"height": request.height,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {"success": False, "error": f"Unsupported format: {request.format}"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to capture region: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/regions")
|
||||||
|
async def get_regions():
|
||||||
|
"""
|
||||||
|
Get predefined screen regions for capture.
|
||||||
|
|
||||||
|
Returns common regions like map area, club indicator, etc.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
# Predefined regions for GSPro UI elements
|
||||||
|
regions = {
|
||||||
|
"map": {
|
||||||
|
"name": "Map Panel",
|
||||||
|
"x": config.capture.region_x,
|
||||||
|
"y": config.capture.region_y,
|
||||||
|
"width": config.capture.region_width,
|
||||||
|
"height": config.capture.region_height,
|
||||||
|
"description": "GSPro mini-map or expanded map view",
|
||||||
|
},
|
||||||
|
"club": {
|
||||||
|
"name": "Club Indicator",
|
||||||
|
"x": 50,
|
||||||
|
"y": 200,
|
||||||
|
"width": 200,
|
||||||
|
"height": 100,
|
||||||
|
"description": "Current club selection display",
|
||||||
|
},
|
||||||
|
"shot_info": {
|
||||||
|
"name": "Shot Information",
|
||||||
|
"x": 50,
|
||||||
|
"y": 50,
|
||||||
|
"width": 300,
|
||||||
|
"height": 150,
|
||||||
|
"description": "Shot distance and trajectory information",
|
||||||
|
},
|
||||||
|
"scorecard": {
|
||||||
|
"name": "Scorecard",
|
||||||
|
"x": 400,
|
||||||
|
"y": 100,
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"description": "Scorecard overlay when visible",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"regions": regions, "total": len(regions)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/regions/{region_name}")
|
||||||
|
async def update_region(region_name: str, region: RegionDefinition):
|
||||||
|
"""
|
||||||
|
Update or create a screen region definition.
|
||||||
|
|
||||||
|
This allows users to define custom regions for their setup.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if region_name == "map":
|
||||||
|
# Update the map region in config
|
||||||
|
config.capture.region_x = region.x
|
||||||
|
config.capture.region_y = region.y
|
||||||
|
config.capture.region_width = region.width
|
||||||
|
config.capture.region_height = region.height
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
return {"success": True, "message": f"Region '{region_name}' updated", "region": region.model_dump()}
|
||||||
|
else:
|
||||||
|
# For now, only map region is persisted
|
||||||
|
# V2 will add support for custom regions
|
||||||
|
return {"success": False, "message": "Custom regions not yet supported (V2 feature)"}
|
||||||
|
|
||||||
|
|
||||||
|
# OCR endpoints - gated behind vision config flag
|
||||||
|
@router.post("/ocr")
|
||||||
|
async def perform_ocr(request: CaptureRequest):
|
||||||
|
"""
|
||||||
|
Perform OCR on a screen region (V2 feature).
|
||||||
|
|
||||||
|
This endpoint is only available when vision features are enabled.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if not config.vision.enabled:
|
||||||
|
raise HTTPException(status_code=403, detail="Vision features are not enabled. This is a V2 feature.")
|
||||||
|
|
||||||
|
# OCR implementation will go here in V2
|
||||||
|
return {"success": False, "message": "OCR features coming in V2", "vision_enabled": config.vision.enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/markers")
|
||||||
|
async def get_markers():
|
||||||
|
"""
|
||||||
|
Get visual markers for template matching (V2 feature).
|
||||||
|
|
||||||
|
This endpoint is only available when vision features are enabled.
|
||||||
|
"""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
if not config.vision.enabled:
|
||||||
|
raise HTTPException(status_code=403, detail="Vision features are not enabled. This is a V2 feature.")
|
||||||
|
|
||||||
|
# Marker management will go here in V2
|
||||||
|
return {"markers": [], "message": "Marker features coming in V2", "vision_enabled": config.vision.enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_vision_status():
|
||||||
|
"""Get the status of vision features."""
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"streaming_enabled": True,
|
||||||
|
"ocr_enabled": config.vision.enabled,
|
||||||
|
"markers_enabled": config.vision.enabled,
|
||||||
|
"active_streams": len(stream_manager.active_streams),
|
||||||
|
"capture_config": {
|
||||||
|
"fps": config.capture.fps,
|
||||||
|
"quality": config.capture.quality,
|
||||||
|
"resolution": config.capture.resolution,
|
||||||
|
},
|
||||||
|
}
|
||||||
21
backend/app/core/__init__.py
Normal file
21
backend/app/core/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""
|
||||||
|
Core modules for GSPro Remote backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import AppConfig, get_config
|
||||||
|
from .input_ctrl import press_key, press_keys, key_down, key_up, focus_window, is_gspro_running
|
||||||
|
from .screen import capture_screen, get_screen_size, capture_region
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppConfig",
|
||||||
|
"get_config",
|
||||||
|
"press_key",
|
||||||
|
"press_keys",
|
||||||
|
"key_down",
|
||||||
|
"key_up",
|
||||||
|
"focus_window",
|
||||||
|
"is_gspro_running",
|
||||||
|
"capture_screen",
|
||||||
|
"get_screen_size",
|
||||||
|
"capture_region",
|
||||||
|
]
|
||||||
193
backend/app/core/config.py
Normal file
193
backend/app/core/config.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""
|
||||||
|
Configuration management for GSPro Remote.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerConfig(BaseModel):
|
||||||
|
"""Server configuration settings."""
|
||||||
|
|
||||||
|
host: str = Field("0.0.0.0", description="Server host address")
|
||||||
|
port: int = Field(5005, description="Server port")
|
||||||
|
mdns_enabled: bool = Field(True, description="Enable mDNS service discovery")
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureConfig(BaseModel):
|
||||||
|
"""Screen capture configuration settings."""
|
||||||
|
|
||||||
|
fps: int = Field(30, description="Frames per second for streaming")
|
||||||
|
quality: int = Field(85, description="JPEG quality (0-100)")
|
||||||
|
resolution: str = Field("720p", description="Stream resolution")
|
||||||
|
region_x: int = Field(0, description="Map region X coordinate")
|
||||||
|
region_y: int = Field(0, description="Map region Y coordinate")
|
||||||
|
region_width: int = Field(640, description="Map region width")
|
||||||
|
region_height: int = Field(480, description="Map region height")
|
||||||
|
|
||||||
|
|
||||||
|
class GSProConfig(BaseModel):
|
||||||
|
"""GSPro application configuration settings."""
|
||||||
|
|
||||||
|
window_title: str = Field("GSPro", description="GSPro window title")
|
||||||
|
auto_focus: bool = Field(True, description="Auto-focus GSPro window before sending keys")
|
||||||
|
key_delay: float = Field(0.05, description="Default delay between key presses (seconds)")
|
||||||
|
|
||||||
|
|
||||||
|
class VisionConfig(BaseModel):
|
||||||
|
"""Computer vision configuration settings (for V2 features)."""
|
||||||
|
|
||||||
|
enabled: bool = Field(False, description="Enable vision features")
|
||||||
|
ocr_engine: str = Field("easyocr", description="OCR engine to use (easyocr or tesseract)")
|
||||||
|
confidence_threshold: float = Field(0.7, description="Minimum confidence for OCR detection")
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseSettings):
|
||||||
|
"""Main application configuration."""
|
||||||
|
|
||||||
|
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||||
|
capture: CaptureConfig = Field(default_factory=CaptureConfig)
|
||||||
|
gspro: GSProConfig = Field(default_factory=GSProConfig)
|
||||||
|
vision: VisionConfig = Field(default_factory=VisionConfig)
|
||||||
|
|
||||||
|
config_path: Optional[Path] = None
|
||||||
|
debug: bool = Field(False, description="Enable debug mode")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_prefix = "GSPRO_REMOTE_"
|
||||||
|
env_nested_delimiter = "__"
|
||||||
|
case_sensitive = False
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
if self.config_path is None:
|
||||||
|
self.config_path = self._get_default_config_path()
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_default_config_path() -> Path:
|
||||||
|
"""Get the default configuration file path."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if os.name == "nt": # Windows
|
||||||
|
base_path = Path(os.environ.get("LOCALAPPDATA", ""))
|
||||||
|
if not base_path:
|
||||||
|
base_path = Path.home() / "AppData" / "Local"
|
||||||
|
else: # Unix-like
|
||||||
|
base_path = Path.home() / ".config"
|
||||||
|
|
||||||
|
config_dir = base_path / "GSPro Remote"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return config_dir / "config.json"
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
"""Load configuration from file."""
|
||||||
|
if self.config_path and self.config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(self.config_path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Update configuration with loaded data
|
||||||
|
if "server" in data:
|
||||||
|
self.server = ServerConfig(**data["server"])
|
||||||
|
if "capture" in data:
|
||||||
|
self.capture = CaptureConfig(**data["capture"])
|
||||||
|
if "gspro" in data:
|
||||||
|
self.gspro = GSProConfig(**data["gspro"])
|
||||||
|
if "vision" in data:
|
||||||
|
self.vision = VisionConfig(**data["vision"])
|
||||||
|
if "debug" in data:
|
||||||
|
self.debug = data["debug"]
|
||||||
|
|
||||||
|
logger.info(f"Configuration loaded from {self.config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load configuration: {e}")
|
||||||
|
self.save() # Save default configuration
|
||||||
|
else:
|
||||||
|
# Create default configuration file
|
||||||
|
self.save()
|
||||||
|
logger.info(f"Created default configuration at {self.config_path}")
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Save configuration to file."""
|
||||||
|
if self.config_path:
|
||||||
|
try:
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"server": self.server.model_dump(),
|
||||||
|
"capture": self.capture.model_dump(),
|
||||||
|
"gspro": self.gspro.model_dump(),
|
||||||
|
"vision": self.vision.model_dump(),
|
||||||
|
"debug": self.debug,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.config_path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
logger.info(f"Configuration saved to {self.config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save configuration: {e}")
|
||||||
|
|
||||||
|
def update(self, **kwargs) -> None:
|
||||||
|
"""Update configuration with new values."""
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(self, key):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Update nested configuration
|
||||||
|
current = getattr(self, key)
|
||||||
|
if isinstance(current, BaseModel):
|
||||||
|
for sub_key, sub_value in value.items():
|
||||||
|
if hasattr(current, sub_key):
|
||||||
|
setattr(current, sub_key, sub_value)
|
||||||
|
else:
|
||||||
|
setattr(self, key, value)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset configuration to defaults."""
|
||||||
|
self.server = ServerConfig()
|
||||||
|
self.capture = CaptureConfig()
|
||||||
|
self.gspro = GSProConfig()
|
||||||
|
self.vision = VisionConfig()
|
||||||
|
self.debug = False
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert configuration to dictionary."""
|
||||||
|
return {
|
||||||
|
"server": self.server.model_dump(),
|
||||||
|
"capture": self.capture.model_dump(),
|
||||||
|
"gspro": self.gspro.model_dump(),
|
||||||
|
"vision": self.vision.model_dump(),
|
||||||
|
"debug": self.debug,
|
||||||
|
"config_path": str(self.config_path) if self.config_path else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global configuration instance
|
||||||
|
_config: Optional[AppConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_config() -> AppConfig:
|
||||||
|
"""Get the global configuration instance."""
|
||||||
|
global _config
|
||||||
|
if _config is None:
|
||||||
|
_config = AppConfig()
|
||||||
|
return _config
|
||||||
|
|
||||||
|
|
||||||
|
def reset_config() -> None:
|
||||||
|
"""Reset the global configuration instance."""
|
||||||
|
global _config
|
||||||
|
_config = None
|
||||||
|
get_config.cache_clear()
|
||||||
350
backend/app/core/input_ctrl.py
Normal file
350
backend/app/core/input_ctrl.py
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
"""
|
||||||
|
Windows input control module for simulating keyboard inputs to GSPro.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pydirectinput
|
||||||
|
import win32gui
|
||||||
|
import win32con
|
||||||
|
import win32process
|
||||||
|
import psutil
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(f"Required Windows dependencies not installed: {e}")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Configure pydirectinput
|
||||||
|
pydirectinput.PAUSE = 0.01 # Reduce default pause between actions
|
||||||
|
|
||||||
|
|
||||||
|
def is_gspro_running(window_title: str = "GSPro") -> bool:
|
||||||
|
"""
|
||||||
|
Check if GSPro is running by looking for its window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_title: The window title to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if GSPro window is found, False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
def enum_window_callback(hwnd, windows):
|
||||||
|
if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd):
|
||||||
|
window_text = win32gui.GetWindowText(hwnd)
|
||||||
|
if window_title.lower() in window_text.lower():
|
||||||
|
windows.append(hwnd)
|
||||||
|
return True
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
win32gui.EnumWindows(enum_window_callback, windows)
|
||||||
|
return len(windows) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def find_gspro_window(window_title: str = "GSPro") -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Find the GSPro window handle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_title: The window title to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Window handle if found, None otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
def enum_window_callback(hwnd, result):
|
||||||
|
window_text = win32gui.GetWindowText(hwnd)
|
||||||
|
if window_title.lower() in window_text.lower():
|
||||||
|
result.append(hwnd)
|
||||||
|
return True
|
||||||
|
|
||||||
|
result = []
|
||||||
|
win32gui.EnumWindows(enum_window_callback, result)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return result[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def focus_window(window_title: str = "GSPro") -> bool:
|
||||||
|
"""
|
||||||
|
Focus the GSPro window to ensure it receives keyboard input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_title: The window title to focus
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if window was focused successfully, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hwnd = find_gspro_window(window_title)
|
||||||
|
if hwnd:
|
||||||
|
# Restore window if minimized
|
||||||
|
if win32gui.IsIconic(hwnd):
|
||||||
|
win32gui.ShowWindow(hwnd, win32con.SW_RESTORE)
|
||||||
|
|
||||||
|
# Set foreground window
|
||||||
|
win32gui.SetForegroundWindow(hwnd)
|
||||||
|
|
||||||
|
# Small delay to ensure window is focused
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
logger.debug(f"Focused window: {window_title}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Window not found: {window_title}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to focus window: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def press_key(key: str, interval: float = 0.0) -> None:
|
||||||
|
"""
|
||||||
|
Simulate a single key press.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to press (e.g., 'a', 'space', 'f1', 'up')
|
||||||
|
interval: Time to wait after pressing the key
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Normalize key name for pydirectinput
|
||||||
|
key_normalized = key.lower().strip()
|
||||||
|
|
||||||
|
# Handle special key mappings
|
||||||
|
key_mappings = {
|
||||||
|
"ctrl": "ctrl",
|
||||||
|
"control": "ctrl",
|
||||||
|
"alt": "alt",
|
||||||
|
"shift": "shift",
|
||||||
|
"tab": "tab",
|
||||||
|
"space": "space",
|
||||||
|
"enter": "enter",
|
||||||
|
"return": "enter",
|
||||||
|
"escape": "esc",
|
||||||
|
"esc": "esc",
|
||||||
|
"backspace": "backspace",
|
||||||
|
"delete": "delete",
|
||||||
|
"del": "delete",
|
||||||
|
"insert": "insert",
|
||||||
|
"ins": "insert",
|
||||||
|
"home": "home",
|
||||||
|
"end": "end",
|
||||||
|
"pageup": "pageup",
|
||||||
|
"pagedown": "pagedown",
|
||||||
|
"up": "up",
|
||||||
|
"down": "down",
|
||||||
|
"left": "left",
|
||||||
|
"right": "right",
|
||||||
|
"plus": "+",
|
||||||
|
"minus": "-",
|
||||||
|
"apostrophe": "'",
|
||||||
|
"quote": "'",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map key if needed
|
||||||
|
key_to_press = key_mappings.get(key_normalized, key_normalized)
|
||||||
|
|
||||||
|
# Press the key
|
||||||
|
pydirectinput.press(key_to_press)
|
||||||
|
|
||||||
|
if interval > 0:
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
logger.debug(f"Pressed key: {key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to press key '{key}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def press_keys(keys: str, interval: float = 0.0) -> None:
|
||||||
|
"""
|
||||||
|
Simulate a key combination or sequence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keys: Key combination string (e.g., 'ctrl+m', 'shift+tab')
|
||||||
|
interval: Time to wait after pressing the keys
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if it's a key combination
|
||||||
|
if "+" in keys:
|
||||||
|
# Split into modifiers and key
|
||||||
|
parts = keys.lower().split("+")
|
||||||
|
modifiers = []
|
||||||
|
main_key = parts[-1]
|
||||||
|
|
||||||
|
# Identify modifiers
|
||||||
|
for part in parts[:-1]:
|
||||||
|
if part in ["ctrl", "control"]:
|
||||||
|
modifiers.append("ctrl")
|
||||||
|
elif part in ["alt"]:
|
||||||
|
modifiers.append("alt")
|
||||||
|
elif part in ["shift"]:
|
||||||
|
modifiers.append("shift")
|
||||||
|
elif part in ["win", "windows", "cmd", "command"]:
|
||||||
|
modifiers.append("win")
|
||||||
|
|
||||||
|
# Press combination using hotkey
|
||||||
|
if modifiers:
|
||||||
|
hotkey_parts = modifiers + [main_key]
|
||||||
|
pydirectinput.hotkey(*hotkey_parts)
|
||||||
|
else:
|
||||||
|
press_key(main_key)
|
||||||
|
else:
|
||||||
|
# Single key press
|
||||||
|
press_key(keys)
|
||||||
|
|
||||||
|
if interval > 0:
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
logger.debug(f"Pressed keys: {keys}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to press keys '{keys}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def key_down(key: str) -> None:
|
||||||
|
"""
|
||||||
|
Hold a key down.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to hold down
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key_normalized = key.lower().strip()
|
||||||
|
pydirectinput.keyDown(key_normalized)
|
||||||
|
logger.debug(f"Key down: {key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to hold key down '{key}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def key_up(key: str) -> None:
|
||||||
|
"""
|
||||||
|
Release a held key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to release
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
key_normalized = key.lower().strip()
|
||||||
|
pydirectinput.keyUp(key_normalized)
|
||||||
|
logger.debug(f"Key up: {key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to release key '{key}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def type_text(text: str, interval: float = 0.0) -> None:
|
||||||
|
"""
|
||||||
|
Type a string of text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to type
|
||||||
|
interval: Time between each character
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pydirectinput.typewrite(text, interval=interval)
|
||||||
|
logger.debug(f"Typed text: {text[:20]}...")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to type text: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def mouse_click(x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None:
|
||||||
|
"""
|
||||||
|
Simulate a mouse click.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: X coordinate (None for current position)
|
||||||
|
y: Y coordinate (None for current position)
|
||||||
|
button: Mouse button ('left', 'right', 'middle')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if x is not None and y is not None:
|
||||||
|
pydirectinput.click(x, y, button=button)
|
||||||
|
logger.debug(f"Mouse click at ({x}, {y}) with {button} button")
|
||||||
|
else:
|
||||||
|
pydirectinput.click(button=button)
|
||||||
|
logger.debug(f"Mouse click with {button} button")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to perform mouse click: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def mouse_move(x: int, y: int, duration: float = 0.0) -> None:
|
||||||
|
"""
|
||||||
|
Move the mouse cursor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Target X coordinate
|
||||||
|
y: Target Y coordinate
|
||||||
|
duration: Time to take for the movement
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if duration > 0:
|
||||||
|
pydirectinput.moveTo(x, y, duration=duration)
|
||||||
|
else:
|
||||||
|
pydirectinput.moveTo(x, y)
|
||||||
|
logger.debug(f"Mouse moved to ({x}, {y})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to move mouse: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_gspro_process_info() -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get information about the GSPro process if it's running.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with process info or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info"]):
|
||||||
|
if "gspro" in proc.info["name"].lower():
|
||||||
|
return {
|
||||||
|
"pid": proc.info["pid"],
|
||||||
|
"name": proc.info["name"],
|
||||||
|
"cpu_percent": proc.info["cpu_percent"],
|
||||||
|
"memory_mb": proc.info["memory_info"].rss / 1024 / 1024 if proc.info["memory_info"] else 0,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get GSPro process info: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Test function for development
|
||||||
|
def test_input_control():
|
||||||
|
"""Test function to verify input control is working."""
|
||||||
|
print("Testing input control...")
|
||||||
|
|
||||||
|
# Check if GSPro is running
|
||||||
|
if is_gspro_running():
|
||||||
|
print("✓ GSPro is running")
|
||||||
|
|
||||||
|
# Try to focus the window
|
||||||
|
if focus_window():
|
||||||
|
print("✓ GSPro window focused")
|
||||||
|
else:
|
||||||
|
print("✗ Could not focus GSPro window")
|
||||||
|
else:
|
||||||
|
print("✗ GSPro is not running")
|
||||||
|
print("Please start GSPro and try again")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get process info
|
||||||
|
info = get_gspro_process_info()
|
||||||
|
if info:
|
||||||
|
print(
|
||||||
|
f"✓ GSPro process found: PID={info['pid']}, CPU={info['cpu_percent']:.1f}%, Memory={info['memory_mb']:.1f}MB"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\nInput control test complete!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run test when module is executed directly
|
||||||
|
test_input_control()
|
||||||
335
backend/app/core/mdns.py
Normal file
335
backend/app/core/mdns.py
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
"""
|
||||||
|
mDNS service registration for GSPro Remote.
|
||||||
|
Allows the application to be discovered on the local network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zeroconf import ServiceInfo, Zeroconf, IPVersion
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(f"Zeroconf library not installed: {e}")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MDNSService:
|
||||||
|
"""
|
||||||
|
Manages mDNS/Bonjour service registration for network discovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "gsproapp",
|
||||||
|
port: int = 5005,
|
||||||
|
service_type: str = "_http._tcp.local.",
|
||||||
|
properties: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize mDNS service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Service name (will be accessible as {name}.local)
|
||||||
|
port: Port number the service is running on
|
||||||
|
service_type: mDNS service type
|
||||||
|
properties: Additional service properties
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.port = port
|
||||||
|
self.service_type = service_type
|
||||||
|
self.properties = properties or {}
|
||||||
|
|
||||||
|
self.zeroconf: Optional[Zeroconf] = None
|
||||||
|
self.service_info: Optional[ServiceInfo] = None
|
||||||
|
self.is_running = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _get_local_ip(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the local IP address of the machine.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Local IP address as string
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create a socket to determine the local IP
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
|
# Connect to a public DNS server to determine local interface
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
except Exception:
|
||||||
|
# Fallback to localhost if can't determine
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
def _create_service_info(self) -> ServiceInfo:
|
||||||
|
"""
|
||||||
|
Create the ServiceInfo object for registration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured ServiceInfo object
|
||||||
|
"""
|
||||||
|
local_ip = self._get_local_ip()
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
|
||||||
|
# Create fully qualified service name
|
||||||
|
service_name = f"{self.name}.{self.service_type}"
|
||||||
|
|
||||||
|
# Add default properties
|
||||||
|
default_properties = {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"platform": "windows",
|
||||||
|
"api": "rest",
|
||||||
|
"ui": "web",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge with custom properties
|
||||||
|
all_properties = {**default_properties, **self.properties}
|
||||||
|
|
||||||
|
# Convert properties to bytes
|
||||||
|
properties_bytes = {}
|
||||||
|
for key, value in all_properties.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
properties_bytes[key] = value.encode("utf-8")
|
||||||
|
elif isinstance(value, bytes):
|
||||||
|
properties_bytes[key] = value
|
||||||
|
else:
|
||||||
|
properties_bytes[key] = str(value).encode("utf-8")
|
||||||
|
|
||||||
|
# Create service info
|
||||||
|
service_info = ServiceInfo(
|
||||||
|
type_=self.service_type,
|
||||||
|
name=service_name,
|
||||||
|
addresses=[socket.inet_aton(local_ip)],
|
||||||
|
port=self.port,
|
||||||
|
properties=properties_bytes,
|
||||||
|
server=f"{hostname}.local.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return service_info
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""
|
||||||
|
Start the mDNS service registration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if service started successfully, False otherwise
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self.is_running:
|
||||||
|
logger.warning("mDNS service is already running")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create Zeroconf instance
|
||||||
|
self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
|
||||||
|
|
||||||
|
# Create and register service
|
||||||
|
self.service_info = self._create_service_info()
|
||||||
|
self.zeroconf.register_service(self.service_info)
|
||||||
|
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
logger.info(f"mDNS service registered: {self.name}.local:{self.port} (type: {self.service_type})")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start mDNS service: {e}")
|
||||||
|
self.cleanup()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the mDNS service registration."""
|
||||||
|
with self._lock:
|
||||||
|
if not self.is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.cleanup()
|
||||||
|
self.is_running = False
|
||||||
|
logger.info("mDNS service stopped")
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Clean up mDNS resources."""
|
||||||
|
try:
|
||||||
|
if self.zeroconf and self.service_info:
|
||||||
|
self.zeroconf.unregister_service(self.service_info)
|
||||||
|
|
||||||
|
if self.zeroconf:
|
||||||
|
self.zeroconf.close()
|
||||||
|
self.zeroconf = None
|
||||||
|
|
||||||
|
self.service_info = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during mDNS cleanup: {e}")
|
||||||
|
|
||||||
|
def update_properties(self, properties: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Update service properties.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
properties: New properties to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if properties updated successfully, False otherwise
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if not self.is_running:
|
||||||
|
logger.warning("Cannot update properties: service is not running")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.properties.update(properties)
|
||||||
|
|
||||||
|
# Recreate and re-register service with new properties
|
||||||
|
if self.zeroconf and self.service_info:
|
||||||
|
self.zeroconf.unregister_service(self.service_info)
|
||||||
|
self.service_info = self._create_service_info()
|
||||||
|
self.zeroconf.register_service(self.service_info)
|
||||||
|
|
||||||
|
logger.info("mDNS service properties updated")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update mDNS properties: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_url(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the URL for accessing the service.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service URL
|
||||||
|
"""
|
||||||
|
return f"http://{self.name}.local:{self.port}"
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Context manager entry."""
|
||||||
|
self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Context manager exit."""
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class MDNSBrowser:
|
||||||
|
"""
|
||||||
|
Browse for mDNS services on the network.
|
||||||
|
Useful for discovering other GSPro Remote instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, service_type: str = "_http._tcp.local."):
|
||||||
|
"""
|
||||||
|
Initialize mDNS browser.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_type: Type of services to browse for
|
||||||
|
"""
|
||||||
|
self.service_type = service_type
|
||||||
|
self.services: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self.zeroconf: Optional[Zeroconf] = None
|
||||||
|
|
||||||
|
def browse(self, timeout: float = 5.0) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Browse for services on the network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Time to wait for services (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of discovered services
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from zeroconf import ServiceBrowser, ServiceListener
|
||||||
|
import time
|
||||||
|
|
||||||
|
class Listener(ServiceListener):
|
||||||
|
def __init__(self, browser):
|
||||||
|
self.browser = browser
|
||||||
|
|
||||||
|
def add_service(self, zeroconf, service_type, name):
|
||||||
|
info = zeroconf.get_service_info(service_type, name)
|
||||||
|
if info:
|
||||||
|
self.browser.services[name] = {
|
||||||
|
"name": name,
|
||||||
|
"address": socket.inet_ntoa(info.addresses[0]) if info.addresses else None,
|
||||||
|
"port": info.port,
|
||||||
|
"properties": info.properties,
|
||||||
|
}
|
||||||
|
|
||||||
|
def remove_service(self, zeroconf, service_type, name):
|
||||||
|
self.browser.services.pop(name, None)
|
||||||
|
|
||||||
|
def update_service(self, zeroconf, service_type, name):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
|
||||||
|
listener = Listener(self)
|
||||||
|
browser = ServiceBrowser(self.zeroconf, self.service_type, listener)
|
||||||
|
|
||||||
|
# Wait for services to be discovered
|
||||||
|
time.sleep(timeout)
|
||||||
|
|
||||||
|
browser.cancel()
|
||||||
|
self.zeroconf.close()
|
||||||
|
|
||||||
|
return self.services
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to browse for services: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# Test function for development
|
||||||
|
def test_mdns_service():
|
||||||
|
"""Test mDNS service registration."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
print("Testing mDNS service registration...")
|
||||||
|
|
||||||
|
# Test service registration
|
||||||
|
service = MDNSService(
|
||||||
|
name="gsproapp-test",
|
||||||
|
port=5005,
|
||||||
|
properties={"test": "true", "instance": "development"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if service.start():
|
||||||
|
print(f"✓ mDNS service started: {service.get_url()}")
|
||||||
|
print(f" You should be able to access it at: http://gsproapp-test.local:5005")
|
||||||
|
|
||||||
|
# Keep service running for 10 seconds
|
||||||
|
print(" Service will run for 10 seconds...")
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
# Test property update
|
||||||
|
if service.update_properties({"status": "running", "uptime": "10s"}):
|
||||||
|
print("✓ Properties updated successfully")
|
||||||
|
|
||||||
|
service.stop()
|
||||||
|
print("✓ mDNS service stopped")
|
||||||
|
else:
|
||||||
|
print("✗ Failed to start mDNS service")
|
||||||
|
|
||||||
|
# Test service browsing
|
||||||
|
print("\nBrowsing for HTTP services on the network...")
|
||||||
|
browser = MDNSBrowser()
|
||||||
|
services = browser.browse(timeout=3.0)
|
||||||
|
|
||||||
|
if services:
|
||||||
|
print(f"Found {len(services)} services:")
|
||||||
|
for name, info in services.items():
|
||||||
|
print(f" - {name}: {info['address']}:{info['port']}")
|
||||||
|
else:
|
||||||
|
print("No services found")
|
||||||
|
|
||||||
|
print("\nmDNS test complete!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_mdns_service()
|
||||||
370
backend/app/core/screen.py
Normal file
370
backend/app/core/screen.py
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
"""
|
||||||
|
Screen capture utilities for GSPro Remote.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple, Dict, Any
|
||||||
|
from io import BytesIO
|
||||||
|
import base64
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mss
|
||||||
|
import mss.tools
|
||||||
|
from PIL import Image
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(f"Required screen capture dependencies not installed: {e}")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenCapture:
|
||||||
|
"""Manages screen capture operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize screen capture manager."""
|
||||||
|
self.sct = mss.mss()
|
||||||
|
self._monitor_info = None
|
||||||
|
|
||||||
|
def get_monitors(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get information about all available monitors.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of monitor information dictionaries
|
||||||
|
"""
|
||||||
|
monitors = []
|
||||||
|
for i, monitor in enumerate(self.sct.monitors):
|
||||||
|
monitors.append(
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"left": monitor["left"],
|
||||||
|
"top": monitor["top"],
|
||||||
|
"width": monitor["width"],
|
||||||
|
"height": monitor["height"],
|
||||||
|
"is_primary": i == 0, # Index 0 is combined virtual screen
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return monitors
|
||||||
|
|
||||||
|
def get_primary_monitor(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get the primary monitor information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Primary monitor information
|
||||||
|
"""
|
||||||
|
# Index 1 is typically the primary monitor in mss
|
||||||
|
return self.sct.monitors[1] if len(self.sct.monitors) > 1 else self.sct.monitors[0]
|
||||||
|
|
||||||
|
def capture_screen(self, monitor_index: int = 1) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Capture the entire screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
monitor_index: Index of the monitor to capture (0 for all, 1 for primary)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Captured screen as numpy array (BGR format)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
monitor = self.sct.monitors[monitor_index]
|
||||||
|
screenshot = self.sct.grab(monitor)
|
||||||
|
|
||||||
|
# Convert to numpy array (BGR format for OpenCV compatibility)
|
||||||
|
img = np.array(screenshot)
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
|
||||||
|
|
||||||
|
return img
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to capture screen: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def capture_region(self, x: int, y: int, width: int, height: int, monitor_index: int = 1) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Capture a specific region of the screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: X coordinate of the region (relative to monitor)
|
||||||
|
y: Y coordinate of the region (relative to monitor)
|
||||||
|
width: Width of the region
|
||||||
|
height: Height of the region
|
||||||
|
monitor_index: Index of the monitor to capture from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Captured region as numpy array (BGR format)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
monitor = self.sct.monitors[monitor_index]
|
||||||
|
|
||||||
|
# Define region to capture
|
||||||
|
region = {
|
||||||
|
"left": monitor["left"] + x,
|
||||||
|
"top": monitor["top"] + y,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshot = self.sct.grab(region)
|
||||||
|
|
||||||
|
# Convert to numpy array
|
||||||
|
img = np.array(screenshot)
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
|
||||||
|
|
||||||
|
return img
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to capture region: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def capture_window(self, window_title: str) -> Optional[np.ndarray]:
|
||||||
|
"""
|
||||||
|
Capture a specific window by title.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_title: Title of the window to capture
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Captured window as numpy array or None if window not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import win32gui
|
||||||
|
|
||||||
|
def enum_window_callback(hwnd, windows):
|
||||||
|
if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd):
|
||||||
|
window_text = win32gui.GetWindowText(hwnd)
|
||||||
|
if window_title.lower() in window_text.lower():
|
||||||
|
windows.append(hwnd)
|
||||||
|
return True
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
win32gui.EnumWindows(enum_window_callback, windows)
|
||||||
|
|
||||||
|
if not windows:
|
||||||
|
logger.warning(f"Window not found: {window_title}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get window rectangle
|
||||||
|
hwnd = windows[0]
|
||||||
|
rect = win32gui.GetWindowRect(hwnd)
|
||||||
|
x, y, x2, y2 = rect
|
||||||
|
width = x2 - x
|
||||||
|
height = y2 - y
|
||||||
|
|
||||||
|
# Capture the window region
|
||||||
|
return self.capture_region(x, y, width, height, monitor_index=0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to capture window: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def image_to_base64(self, image: np.ndarray, quality: int = 85, format: str = "JPEG") -> str:
|
||||||
|
"""
|
||||||
|
Convert an image array to base64 string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: Image as numpy array (BGR format)
|
||||||
|
quality: JPEG quality (1-100)
|
||||||
|
format: Image format (JPEG, PNG)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 encoded image string
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert BGR to RGB for PIL
|
||||||
|
rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||||
|
pil_image = Image.fromarray(rgb_image)
|
||||||
|
|
||||||
|
# Save to bytes
|
||||||
|
buffer = BytesIO()
|
||||||
|
if format.upper() == "JPEG":
|
||||||
|
pil_image.save(buffer, format=format, quality=quality, optimize=True)
|
||||||
|
else:
|
||||||
|
pil_image.save(buffer, format=format)
|
||||||
|
|
||||||
|
# Encode to base64
|
||||||
|
buffer.seek(0)
|
||||||
|
base64_string = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
return base64_string
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to convert image to base64: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def resize_image(self, image: np.ndarray, width: Optional[int] = None, height: Optional[int] = None) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Resize an image while maintaining aspect ratio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: Image as numpy array
|
||||||
|
width: Target width (None to calculate from height)
|
||||||
|
height: Target height (None to calculate from width)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resized image as numpy array
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
|
||||||
|
if width and not height:
|
||||||
|
# Calculate height maintaining aspect ratio
|
||||||
|
height = int(h * (width / w))
|
||||||
|
elif height and not width:
|
||||||
|
# Calculate width maintaining aspect ratio
|
||||||
|
width = int(w * (height / h))
|
||||||
|
elif not width and not height:
|
||||||
|
# No resize needed
|
||||||
|
return image
|
||||||
|
|
||||||
|
return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to resize image: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_resolution_preset(self, preset: str) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Get width and height for a resolution preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Resolution preset (e.g., '720p', '1080p', '480p')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (width, height)
|
||||||
|
"""
|
||||||
|
presets = {
|
||||||
|
"480p": (854, 480),
|
||||||
|
"540p": (960, 540),
|
||||||
|
"720p": (1280, 720),
|
||||||
|
"900p": (1600, 900),
|
||||||
|
"1080p": (1920, 1080),
|
||||||
|
"1440p": (2560, 1440),
|
||||||
|
"2160p": (3840, 2160),
|
||||||
|
"4k": (3840, 2160),
|
||||||
|
}
|
||||||
|
|
||||||
|
return presets.get(preset.lower(), (1280, 720))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close the screen capture resources."""
|
||||||
|
if hasattr(self, "sct"):
|
||||||
|
self.sct.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Global screen capture instance
|
||||||
|
_screen_capture: Optional[ScreenCapture] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_screen_capture() -> ScreenCapture:
|
||||||
|
"""
|
||||||
|
Get the global screen capture instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScreenCapture instance
|
||||||
|
"""
|
||||||
|
global _screen_capture
|
||||||
|
if _screen_capture is None:
|
||||||
|
_screen_capture = ScreenCapture()
|
||||||
|
return _screen_capture
|
||||||
|
|
||||||
|
|
||||||
|
def capture_screen(monitor_index: int = 1) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Capture the entire screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
monitor_index: Index of the monitor to capture
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Captured screen as numpy array
|
||||||
|
"""
|
||||||
|
return get_screen_capture().capture_screen(monitor_index)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_region(x: int, y: int, width: int, height: int) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Capture a specific region of the screen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: X coordinate of the region
|
||||||
|
y: Y coordinate of the region
|
||||||
|
width: Width of the region
|
||||||
|
height: Height of the region
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Captured region as numpy array
|
||||||
|
"""
|
||||||
|
return get_screen_capture().capture_region(x, y, width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def get_screen_size() -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Get the primary screen size.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (width, height)
|
||||||
|
"""
|
||||||
|
monitor = get_screen_capture().get_primary_monitor()
|
||||||
|
return monitor["width"], monitor["height"]
|
||||||
|
|
||||||
|
|
||||||
|
def capture_gspro_window(window_title: str = "GSPro") -> Optional[np.ndarray]:
|
||||||
|
"""
|
||||||
|
Capture the GSPro window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_title: GSPro window title
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Captured window as numpy array or None if not found
|
||||||
|
"""
|
||||||
|
return get_screen_capture().capture_window(window_title)
|
||||||
|
|
||||||
|
|
||||||
|
# Test function for development
|
||||||
|
def test_screen_capture():
|
||||||
|
"""Test screen capture functionality."""
|
||||||
|
print("Testing screen capture...")
|
||||||
|
|
||||||
|
capture = ScreenCapture()
|
||||||
|
|
||||||
|
# Get monitor information
|
||||||
|
monitors = capture.get_monitors()
|
||||||
|
print(f"Found {len(monitors)} monitors:")
|
||||||
|
for monitor in monitors:
|
||||||
|
print(
|
||||||
|
f" Monitor {monitor['index']}: {monitor['width']}x{monitor['height']} at ({monitor['left']}, {monitor['top']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capture primary screen
|
||||||
|
try:
|
||||||
|
screen = capture.capture_screen()
|
||||||
|
print(f"✓ Captured primary screen: {screen.shape}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Failed to capture screen: {e}")
|
||||||
|
|
||||||
|
# Test region capture
|
||||||
|
try:
|
||||||
|
region = capture.capture_region(100, 100, 640, 480)
|
||||||
|
print(f"✓ Captured region: {region.shape}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Failed to capture region: {e}")
|
||||||
|
|
||||||
|
# Test image to base64 conversion
|
||||||
|
try:
|
||||||
|
base64_str = capture.image_to_base64(region)
|
||||||
|
print(f"✓ Converted to base64: {len(base64_str)} chars")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Failed to convert to base64: {e}")
|
||||||
|
|
||||||
|
# Test resolution presets
|
||||||
|
presets = ["480p", "720p", "1080p"]
|
||||||
|
for preset in presets:
|
||||||
|
width, height = capture.get_resolution_preset(preset)
|
||||||
|
print(f" {preset}: {width}x{height}")
|
||||||
|
|
||||||
|
capture.close()
|
||||||
|
print("\nScreen capture test complete!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_screen_capture()
|
||||||
115
backend/app/main.py
Normal file
115
backend/app/main.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""
|
||||||
|
Main FastAPI application for GSPro Remote backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .api import actions, config, system, vision
|
||||||
|
from .core.config import AppConfig, get_config
|
||||||
|
from .core.mdns import MDNSService
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""
|
||||||
|
Application lifespan manager for startup/shutdown tasks.
|
||||||
|
"""
|
||||||
|
# Startup
|
||||||
|
logger.info("Starting GSPro Remote backend v0.1.0")
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config = get_config()
|
||||||
|
logger.info(f"Configuration loaded from {config.config_path}")
|
||||||
|
|
||||||
|
# Start mDNS service if enabled
|
||||||
|
mdns_service = None
|
||||||
|
if config.server.mdns_enabled:
|
||||||
|
try:
|
||||||
|
mdns_service = MDNSService(name="gsproapp", port=config.server.port, service_type="_http._tcp.local.")
|
||||||
|
mdns_service.start()
|
||||||
|
logger.info(f"mDNS service started: gsproapp.local:{config.server.port}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to start mDNS service: {e}")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Shutting down GSPro Remote backend")
|
||||||
|
|
||||||
|
# Stop mDNS service
|
||||||
|
if mdns_service:
|
||||||
|
mdns_service.stop()
|
||||||
|
logger.info("mDNS service stopped")
|
||||||
|
|
||||||
|
# Save configuration
|
||||||
|
config.save()
|
||||||
|
logger.info("Configuration saved")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""
|
||||||
|
Create and configure the FastAPI application.
|
||||||
|
"""
|
||||||
|
app = FastAPI(
|
||||||
|
title="GSPro Remote",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Remote control API for GSPro golf simulator",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url="/api/redoc",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # In production, specify actual origins
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount API routers
|
||||||
|
app.include_router(actions.router, prefix="/api/actions", tags=["Actions"])
|
||||||
|
app.include_router(config.router, prefix="/api/config", tags=["Configuration"])
|
||||||
|
app.include_router(vision.router, prefix="/api/vision", tags=["Vision"])
|
||||||
|
app.include_router(system.router, prefix="/api/system", tags=["System"])
|
||||||
|
|
||||||
|
# Serve frontend UI if built
|
||||||
|
ui_path = Path(__file__).parent.parent / "ui"
|
||||||
|
if ui_path.exists():
|
||||||
|
app.mount("/ui", StaticFiles(directory=str(ui_path), html=True), name="ui")
|
||||||
|
logger.info(f"Serving UI from {ui_path}")
|
||||||
|
|
||||||
|
# Root redirect
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"name": "GSPro Remote",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": "running",
|
||||||
|
"ui": "/ui" if ui_path.exists() else None,
|
||||||
|
"docs": "/api/docs",
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# Create the application instance
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
uvicorn.run("app.main:app", host=config.server.host, port=config.server.port, reload=True, log_level="info")
|
||||||
76
backend/pyproject.toml
Normal file
76
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "gspro-remote"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Remote control application for GSPro golf simulator"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "GSPro Remote Team"},
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.104.0",
|
||||||
|
"uvicorn[standard]>=0.24.0",
|
||||||
|
"pydantic>=2.5.0",
|
||||||
|
"pydantic-settings>=2.1.0",
|
||||||
|
"pydirectinput>=1.0.4",
|
||||||
|
"pywin32>=306",
|
||||||
|
"mss>=9.0.1",
|
||||||
|
"opencv-python>=4.8.0",
|
||||||
|
"pillow>=10.1.0",
|
||||||
|
"zeroconf>=0.120.0",
|
||||||
|
"websockets>=12.0",
|
||||||
|
"python-multipart>=0.0.6",
|
||||||
|
"aiofiles>=23.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.21.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"black>=23.11.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.7.0",
|
||||||
|
"httpx>=0.25.0",
|
||||||
|
]
|
||||||
|
vision = [
|
||||||
|
"easyocr>=1.7.0",
|
||||||
|
"pytesseract>=0.3.10",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
]
|
||||||
|
build = [
|
||||||
|
"pyinstaller>=6.0.0",
|
||||||
|
"nuitka>=1.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["app*"]
|
||||||
|
exclude = ["tests*"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ['py311']
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
select = ["E", "F", "I", "N", "W"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = "-v --cov=app --cov-report=html --cov-report=term"
|
||||||
27
backend/requirements.txt
Normal file
27
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Core dependencies
|
||||||
|
fastapi>=0.104.0
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
pydantic>=2.5.0
|
||||||
|
pydantic-settings>=2.1.0
|
||||||
|
|
||||||
|
# Windows input control
|
||||||
|
pydirectinput>=1.0.4
|
||||||
|
pywin32>=306; sys_platform == 'win32'
|
||||||
|
|
||||||
|
# Screen capture and image processing
|
||||||
|
mss>=9.0.1
|
||||||
|
opencv-python>=4.8.0
|
||||||
|
pillow>=10.1.0
|
||||||
|
|
||||||
|
# Networking and service discovery
|
||||||
|
zeroconf>=0.120.0
|
||||||
|
websockets>=12.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
aiofiles>=23.2.1
|
||||||
|
|
||||||
|
# System utilities
|
||||||
|
psutil>=5.9.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
|
||||||
|
# HTTP client for testing
|
||||||
|
httpx>=0.25.0
|
||||||
182
frontend/index.html
Normal file
182
frontend/index.html
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#2D5016" />
|
||||||
|
<meta name="description" content="Remote control application for GSPro golf simulator" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="GSPro Remote" />
|
||||||
|
|
||||||
|
<!-- PWA manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<title>GSPro Remote</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Prevent text selection and touch highlighting for better mobile UX */
|
||||||
|
body {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow text selection in specific areas if needed */
|
||||||
|
.selectable {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
-ms-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading screen styles */
|
||||||
|
#loading-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loading-screen.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logo {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
border-radius: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 20px 40px rgba(34, 197, 94, 0.3);
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logo svg {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid #1e293b;
|
||||||
|
border-top-color: #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-white overflow-hidden">
|
||||||
|
<!-- Loading Screen -->
|
||||||
|
<div id="loading-screen">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="loading-logo">
|
||||||
|
<!-- Golf flag icon -->
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="loading-title">GSPro Remote</h1>
|
||||||
|
<p class="loading-subtitle">Connecting to GSPro...</p>
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- React Root -->
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Remove loading screen when React app is ready
|
||||||
|
window.addEventListener('app-ready', function() {
|
||||||
|
const loadingScreen = document.getElementById('loading-screen');
|
||||||
|
if (loadingScreen) {
|
||||||
|
loadingScreen.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingScreen.style.display = 'none';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent pull-to-refresh on mobile
|
||||||
|
document.addEventListener('touchmove', function(e) {
|
||||||
|
if (e.touches.length > 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Handle viewport height on mobile devices
|
||||||
|
function setViewportHeight() {
|
||||||
|
const vh = window.innerHeight * 0.01;
|
||||||
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewportHeight();
|
||||||
|
window.addEventListener('resize', setViewportHeight);
|
||||||
|
window.addEventListener('orientationchange', setViewportHeight);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4813
frontend/package-lock.json
generated
Normal file
4813
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "gspro-remote-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"react-icons": "^4.12.0",
|
||||||
|
"zustand": "^4.4.7",
|
||||||
|
"react-hot-toast": "^2.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
60
frontend/public/manifest.json
Normal file
60
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "GSPro Remote",
|
||||||
|
"short_name": "GSPro Remote",
|
||||||
|
"description": "Remote control application for GSPro golf simulator",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"manifest_version": 3,
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0f172a",
|
||||||
|
"theme_color": "#2D5016",
|
||||||
|
"categories": ["sports", "games", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Quick Launch",
|
||||||
|
"short_name": "Launch",
|
||||||
|
"description": "Quick launch GSPro Remote",
|
||||||
|
"url": "/",
|
||||||
|
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/screenshot1.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "1280x720",
|
||||||
|
"label": "Main control interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/screenshot2.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "1280x720",
|
||||||
|
"label": "Map view with streaming"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"related_applications": [],
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"scope": "/",
|
||||||
|
"launch_handler": {
|
||||||
|
"client_mode": "navigate-existing"
|
||||||
|
},
|
||||||
|
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||||
|
"edge_side_panel": {
|
||||||
|
"preferred_width": 480
|
||||||
|
}
|
||||||
|
}
|
||||||
131
frontend/src/App.tsx
Normal file
131
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import DynamicGolfUI from './pages/DynamicGolfUI'
|
||||||
|
import ConnectionStatus from './components/ConnectionStatus'
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
|
import { useStore } from './stores/appStore'
|
||||||
|
import { checkBackendConnection } from './api/system'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const { setConnectionStatus } = useStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check backend connection on mount
|
||||||
|
const checkConnection = async () => {
|
||||||
|
try {
|
||||||
|
const result = await checkBackendConnection()
|
||||||
|
setIsConnected(result)
|
||||||
|
setConnectionStatus(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to backend:', error)
|
||||||
|
setIsConnected(false)
|
||||||
|
setConnectionStatus(false)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkConnection()
|
||||||
|
|
||||||
|
// Set up periodic connection check
|
||||||
|
const interval = setInterval(checkConnection, 5000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [setConnectionStatus])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen-safe flex items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-400">Connecting to GSPro Remote...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen-safe flex items-center justify-center bg-gray-900 p-4">
|
||||||
|
<div className="card max-w-md w-full">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<div className="w-20 h-20 bg-error-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-10 h-10 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Connection Error</h2>
|
||||||
|
<p className="text-gray-400 mb-6">
|
||||||
|
Unable to connect to the GSPro Remote backend server.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 mb-6 text-left">
|
||||||
|
<p className="text-sm text-gray-300 mb-2">Please ensure:</p>
|
||||||
|
<ul className="text-sm text-gray-400 space-y-1">
|
||||||
|
<li>• The backend server is running</li>
|
||||||
|
<li>• You're on the same network</li>
|
||||||
|
<li>• Port 5005 is accessible</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
|
Retry Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className="h-screen-safe bg-gray-900 overflow-hidden">
|
||||||
|
{/* Connection Status Bar */}
|
||||||
|
<ConnectionStatus />
|
||||||
|
|
||||||
|
{/* Main UI */}
|
||||||
|
<DynamicGolfUI />
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 3000,
|
||||||
|
style: {
|
||||||
|
background: '#1f2937',
|
||||||
|
color: '#fff',
|
||||||
|
border: '1px solid #374151',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#22c55e',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
308
frontend/src/api/client.ts
Normal file
308
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import axios, { AxiosInstance, AxiosError } from "axios";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
// Determine API base URL based on current location
|
||||||
|
const getApiBaseUrl = () => {
|
||||||
|
// If we're running on localhost (development), use localhost backend
|
||||||
|
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
|
||||||
|
return "http://localhost:5005";
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're accessing from a network IP, use the same IP for backend
|
||||||
|
// This assumes backend is running on same machine as frontend
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
return `${protocol}//${hostname}:5005`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || getApiBaseUrl();
|
||||||
|
|
||||||
|
// Log the API URL for debugging
|
||||||
|
console.log("API Base URL:", API_BASE_URL);
|
||||||
|
|
||||||
|
// Create axios instance with default config
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// Add any auth tokens or request modifications here
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
// Handle common errors
|
||||||
|
if (error.code === "ECONNABORTED") {
|
||||||
|
toast.error("Request timeout - please try again");
|
||||||
|
} else if (error.code === "ERR_NETWORK") {
|
||||||
|
toast.error("Cannot connect to backend - check if server is running");
|
||||||
|
} else if (error.response) {
|
||||||
|
// Server responded with error
|
||||||
|
const message = (error.response.data as any)?.detail || "An error occurred";
|
||||||
|
|
||||||
|
if (error.response.status === 409) {
|
||||||
|
// GSPro not running
|
||||||
|
toast.error("GSPro is not running or window not found");
|
||||||
|
} else if (error.response.status >= 500) {
|
||||||
|
toast.error(`Server error: ${message}`);
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
// No response from server
|
||||||
|
console.error("Backend connection failed:", error.message);
|
||||||
|
toast.error("Cannot connect to backend server on " + API_BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions API
|
||||||
|
export const actionsAPI = {
|
||||||
|
sendKey: async (key: string, delay?: number) => {
|
||||||
|
const response = await apiClient.post("/api/actions/key", { key, delay });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
sendKeyDown: async (key: string, duration?: number) => {
|
||||||
|
const response = await apiClient.post("/api/actions/keydown", { key, duration });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
sendKeyUp: async (key: string) => {
|
||||||
|
const response = await apiClient.post("/api/actions/keyup", { key });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
sendKeySequence: async (keys: string[], interval?: number) => {
|
||||||
|
const response = await apiClient.post("/api/actions/sequence", { keys, interval });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getShortcuts: async () => {
|
||||||
|
const response = await apiClient.get("/api/actions/shortcuts");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config API
|
||||||
|
export const configAPI = {
|
||||||
|
getConfig: async () => {
|
||||||
|
const response = await apiClient.get("/api/config");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConfig: async (config: any) => {
|
||||||
|
const response = await apiClient.put("/api/config", config);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveConfig: async () => {
|
||||||
|
const response = await apiClient.post("/api/config/save");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetConfig: async () => {
|
||||||
|
const response = await apiClient.post("/api/config/reset");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vision API
|
||||||
|
export const visionAPI = {
|
||||||
|
captureRegion: async (x: number, y: number, width: number, height: number) => {
|
||||||
|
const response = await apiClient.post("/api/vision/capture", {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format: "base64",
|
||||||
|
quality: 85,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRegions: async () => {
|
||||||
|
const response = await apiClient.get("/api/vision/regions");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRegion: async (name: string, region: any) => {
|
||||||
|
const response = await apiClient.post(`/api/vision/regions/${name}`, region);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatus: async () => {
|
||||||
|
const response = await apiClient.get("/api/vision/status");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// System API
|
||||||
|
export const systemAPI = {
|
||||||
|
getHealth: async () => {
|
||||||
|
const response = await apiClient.get("/api/system/health");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getInfo: async () => {
|
||||||
|
const response = await apiClient.get("/api/system/info");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getGSProStatus: async () => {
|
||||||
|
const response = await apiClient.get("/api/system/gspro/status");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
findGSProWindow: async () => {
|
||||||
|
const response = await apiClient.get("/api/system/gspro/find");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMetrics: async () => {
|
||||||
|
const response = await apiClient.get("/api/system/metrics");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
runDiagnostics: async () => {
|
||||||
|
const response = await apiClient.get("/api/system/diagnostics");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// WebSocket connection for streaming
|
||||||
|
export class StreamingClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectAttempts = 5;
|
||||||
|
private reconnectDelay = 1000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private onFrame: (data: any) => void,
|
||||||
|
private onError?: (error: any) => void,
|
||||||
|
private onConnect?: () => void,
|
||||||
|
private onDisconnect?: () => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
connect(config: any = {}) {
|
||||||
|
// Use same logic for WebSocket URL
|
||||||
|
let wsUrl: string;
|
||||||
|
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
|
||||||
|
wsUrl = "ws://localhost:5005";
|
||||||
|
} else {
|
||||||
|
// Use the same hostname but with ws:// protocol
|
||||||
|
wsUrl = `ws://${window.location.hostname}:5005`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("WebSocket URL:", wsUrl);
|
||||||
|
this.ws = new WebSocket(`${wsUrl}/api/vision/ws/stream`);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log("WebSocket connected");
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// Send configuration
|
||||||
|
this.ws?.send(
|
||||||
|
JSON.stringify({
|
||||||
|
action: "start",
|
||||||
|
config: {
|
||||||
|
fps: config.fps || 30,
|
||||||
|
quality: config.quality || 85,
|
||||||
|
resolution: config.resolution || "720p",
|
||||||
|
region_x: config.region_x || 0,
|
||||||
|
region_y: config.region_y || 0,
|
||||||
|
region_width: config.region_width || 640,
|
||||||
|
region_height: config.region_height || 480,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.onConnect?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === "frame") {
|
||||||
|
this.onFrame(data);
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
console.error("Stream error:", data.message);
|
||||||
|
this.onError?.(data.message);
|
||||||
|
} else if (data.type === "config") {
|
||||||
|
console.log("Stream config:", data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse WebSocket message:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
this.onError?.(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.log("WebSocket disconnected");
|
||||||
|
this.onDisconnect?.();
|
||||||
|
|
||||||
|
// Attempt reconnection
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`);
|
||||||
|
setTimeout(() => this.connect(config), this.reconnectDelay * this.reconnectAttempts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(config: any) {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
action: "update",
|
||||||
|
config,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a function to manually check backend connection
|
||||||
|
export async function checkBackendConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await systemAPI.getHealth();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Backend connection check failed:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the current API base URL for debugging
|
||||||
|
export function getCurrentApiUrl(): string {
|
||||||
|
return API_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
123
frontend/src/api/system.ts
Normal file
123
frontend/src/api/system.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { systemAPI } from './client'
|
||||||
|
|
||||||
|
export interface SystemHealth {
|
||||||
|
status: string
|
||||||
|
timestamp: string
|
||||||
|
uptime_seconds: number
|
||||||
|
services: {
|
||||||
|
api: string
|
||||||
|
gspro: string
|
||||||
|
mdns: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GSProStatus {
|
||||||
|
running: boolean
|
||||||
|
window_title: string
|
||||||
|
process: {
|
||||||
|
pid: number
|
||||||
|
name: string
|
||||||
|
cpu_percent: number
|
||||||
|
memory_mb: number
|
||||||
|
} | null
|
||||||
|
auto_focus: boolean
|
||||||
|
key_delay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let connectionCheckPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
export async function checkBackendConnection(): Promise<boolean> {
|
||||||
|
// Prevent multiple simultaneous connection checks
|
||||||
|
if (connectionCheckPromise) {
|
||||||
|
return connectionCheckPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionCheckPromise = systemAPI.getHealth()
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
.finally(() => {
|
||||||
|
connectionCheckPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return connectionCheckPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkGSProStatus(): Promise<GSProStatus | null> {
|
||||||
|
try {
|
||||||
|
const status = await systemAPI.getGSProStatus()
|
||||||
|
return status
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get GSPro status:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemHealth(): Promise<SystemHealth | null> {
|
||||||
|
try {
|
||||||
|
const health = await systemAPI.getHealth()
|
||||||
|
return health
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get system health:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findGSProWindows(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const result = await systemAPI.findGSProWindow()
|
||||||
|
return result.windows || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to find GSPro windows:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSystemDiagnostics(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const diagnostics = await systemAPI.runDiagnostics()
|
||||||
|
return diagnostics
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to run diagnostics:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUptime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${secs}s`
|
||||||
|
} else {
|
||||||
|
return `${secs}s`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectionStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
return 'text-green-500'
|
||||||
|
case 'disconnected':
|
||||||
|
return 'text-red-500'
|
||||||
|
case 'connecting':
|
||||||
|
return 'text-yellow-500'
|
||||||
|
default:
|
||||||
|
return 'text-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectionStatusIcon(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
return '●'
|
||||||
|
case 'disconnected':
|
||||||
|
return '○'
|
||||||
|
case 'connecting':
|
||||||
|
return '◐'
|
||||||
|
default:
|
||||||
|
return '◯'
|
||||||
|
}
|
||||||
|
}
|
||||||
281
frontend/src/components/AimPad.tsx
Normal file
281
frontend/src/components/AimPad.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { actionsAPI } from '../api/client'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
interface AimPadProps {
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
}
|
||||||
|
|
||||||
|
const AimPad: React.FC<AimPadProps> = ({ size = 'large' }) => {
|
||||||
|
const { settings, setAimDirection } = useStore()
|
||||||
|
const [activeDirection, setActiveDirection] = useState<string | null>(null)
|
||||||
|
const [isHolding, setIsHolding] = useState(false)
|
||||||
|
|
||||||
|
// Size configurations
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'w-32 h-32',
|
||||||
|
medium: 'w-48 h-48',
|
||||||
|
large: 'w-64 h-64',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonSizeClasses = {
|
||||||
|
small: 'text-xl',
|
||||||
|
medium: 'text-2xl',
|
||||||
|
large: 'text-3xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle key press/release
|
||||||
|
const handleKeyAction = useCallback(async (key: string, action: 'down' | 'up') => {
|
||||||
|
try {
|
||||||
|
if (action === 'down') {
|
||||||
|
await actionsAPI.sendKeyDown(key)
|
||||||
|
setIsHolding(true)
|
||||||
|
|
||||||
|
// Haptic feedback on mobile
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await actionsAPI.sendKeyUp(key)
|
||||||
|
setIsHolding(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to send key ${action}:`, error)
|
||||||
|
if (action === 'down') {
|
||||||
|
toast.error('Failed to send command')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [settings.hapticFeedback])
|
||||||
|
|
||||||
|
// Handle single press
|
||||||
|
const handlePress = useCallback(async (key: string, direction?: string) => {
|
||||||
|
try {
|
||||||
|
await actionsAPI.sendKey(key)
|
||||||
|
|
||||||
|
if (direction) {
|
||||||
|
// Flash the button
|
||||||
|
setActiveDirection(direction)
|
||||||
|
setTimeout(() => setActiveDirection(null), 150)
|
||||||
|
|
||||||
|
// Update aim direction in store
|
||||||
|
if (direction === 'up') setAimDirection({ x: 0, y: 1 })
|
||||||
|
else if (direction === 'down') setAimDirection({ x: 0, y: -1 })
|
||||||
|
else if (direction === 'left') setAimDirection({ x: -1, y: 0 })
|
||||||
|
else if (direction === 'right') setAimDirection({ x: 1, y: 0 })
|
||||||
|
else if (direction === 'center') setAimDirection({ x: 0, y: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haptic feedback
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send key:', error)
|
||||||
|
}
|
||||||
|
}, [settings.hapticFeedback, setAimDirection])
|
||||||
|
|
||||||
|
// Touch handlers for hold functionality
|
||||||
|
const handleTouchStart = (key: string, direction: string) => {
|
||||||
|
setActiveDirection(direction)
|
||||||
|
handleKeyAction(key, 'down')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = (key: string) => {
|
||||||
|
setActiveDirection(null)
|
||||||
|
if (isHolding) {
|
||||||
|
handleKeyAction(key, 'up')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse handlers for desktop
|
||||||
|
const handleMouseDown = (key: string, direction: string) => {
|
||||||
|
setActiveDirection(direction)
|
||||||
|
handleKeyAction(key, 'down')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = (key: string) => {
|
||||||
|
setActiveDirection(null)
|
||||||
|
if (isHolding) {
|
||||||
|
handleKeyAction(key, 'up')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = (key: string) => {
|
||||||
|
if (isHolding) {
|
||||||
|
setActiveDirection(null)
|
||||||
|
handleKeyAction(key, 'up')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (isHolding) {
|
||||||
|
// Clean up any held keys
|
||||||
|
['up', 'down', 'left', 'right'].forEach(key => {
|
||||||
|
actionsAPI.sendKeyUp(key).catch(() => {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isHolding])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* D-Pad Container */}
|
||||||
|
<div className={`relative ${sizeClasses[size]} select-none`}>
|
||||||
|
{/* Background circle */}
|
||||||
|
<div className="absolute inset-0 bg-gray-800 rounded-full shadow-2xl"></div>
|
||||||
|
|
||||||
|
{/* Center lines */}
|
||||||
|
<div className="absolute inset-x-0 top-1/2 h-px bg-gray-700 -translate-y-1/2"></div>
|
||||||
|
<div className="absolute inset-y-0 left-1/2 w-px bg-gray-700 -translate-x-1/2"></div>
|
||||||
|
|
||||||
|
{/* Up Button */}
|
||||||
|
<button
|
||||||
|
className={`absolute top-0 left-1/2 -translate-x-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-t-full transition-all ${
|
||||||
|
activeDirection === 'up'
|
||||||
|
? 'bg-primary-600 text-white scale-95'
|
||||||
|
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
onMouseDown={() => handleMouseDown('up', 'up')}
|
||||||
|
onMouseUp={() => handleMouseUp('up')}
|
||||||
|
onMouseLeave={() => handleMouseLeave('up')}
|
||||||
|
onTouchStart={() => handleTouchStart('up', 'up')}
|
||||||
|
onTouchEnd={() => handleTouchEnd('up')}
|
||||||
|
aria-label="Aim Up"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`${buttonSizeClasses[size]} pointer-events-none`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Down Button */}
|
||||||
|
<button
|
||||||
|
className={`absolute bottom-0 left-1/2 -translate-x-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-b-full transition-all ${
|
||||||
|
activeDirection === 'down'
|
||||||
|
? 'bg-primary-600 text-white scale-95'
|
||||||
|
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
onMouseDown={() => handleMouseDown('down', 'down')}
|
||||||
|
onMouseUp={() => handleMouseUp('down')}
|
||||||
|
onMouseLeave={() => handleMouseLeave('down')}
|
||||||
|
onTouchStart={() => handleTouchStart('down', 'down')}
|
||||||
|
onTouchEnd={() => handleTouchEnd('down')}
|
||||||
|
aria-label="Aim Down"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`${buttonSizeClasses[size]} pointer-events-none`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Left Button */}
|
||||||
|
<button
|
||||||
|
className={`absolute left-0 top-1/2 -translate-y-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-l-full transition-all ${
|
||||||
|
activeDirection === 'left'
|
||||||
|
? 'bg-primary-600 text-white scale-95'
|
||||||
|
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
onMouseDown={() => handleMouseDown('left', 'left')}
|
||||||
|
onMouseUp={() => handleMouseUp('left')}
|
||||||
|
onMouseLeave={() => handleMouseLeave('left')}
|
||||||
|
onTouchStart={() => handleTouchStart('left', 'left')}
|
||||||
|
onTouchEnd={() => handleTouchEnd('left')}
|
||||||
|
aria-label="Aim Left"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`${buttonSizeClasses[size]} pointer-events-none`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Right Button */}
|
||||||
|
<button
|
||||||
|
className={`absolute right-0 top-1/2 -translate-y-1/2 w-1/3 h-1/3 flex items-center justify-center rounded-r-full transition-all ${
|
||||||
|
activeDirection === 'right'
|
||||||
|
? 'bg-primary-600 text-white scale-95'
|
||||||
|
: 'hover:bg-gray-700 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
onMouseDown={() => handleMouseDown('right', 'right')}
|
||||||
|
onMouseUp={() => handleMouseUp('right')}
|
||||||
|
onMouseLeave={() => handleMouseLeave('right')}
|
||||||
|
onTouchStart={() => handleTouchStart('right', 'right')}
|
||||||
|
onTouchEnd={() => handleTouchEnd('right')}
|
||||||
|
aria-label="Aim Right"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`${buttonSizeClasses[size]} pointer-events-none`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Center Reset Button */}
|
||||||
|
<button
|
||||||
|
className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1/4 h-1/4 flex items-center justify-center rounded-full transition-all ${
|
||||||
|
activeDirection === 'center'
|
||||||
|
? 'bg-success-600 text-white scale-95'
|
||||||
|
: 'bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => handlePress('a', 'center')}
|
||||||
|
aria-label="Reset Aim"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`${buttonSizeClasses[size]} pointer-events-none`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<span className="text-sm text-gray-400 uppercase tracking-wider">Aim Control</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hold indicator */}
|
||||||
|
{isHolding && (
|
||||||
|
<div className="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 bg-primary-600 text-white text-xs rounded animate-pulse">
|
||||||
|
Holding...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AimPad
|
||||||
168
frontend/src/components/ClubIndicator.tsx
Normal file
168
frontend/src/components/ClubIndicator.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
import { actionsAPI } from '../api/client'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
interface ClubIndicatorProps {
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClubIndicator: React.FC<ClubIndicatorProps> = ({ compact = false }) => {
|
||||||
|
const { club, nextClub, previousClub, settings } = useStore()
|
||||||
|
|
||||||
|
const handleClubChange = async (direction: 'up' | 'down') => {
|
||||||
|
try {
|
||||||
|
const key = direction === 'up' ? 'u' : 'k'
|
||||||
|
await actionsAPI.sendKey(key)
|
||||||
|
|
||||||
|
if (direction === 'up') {
|
||||||
|
previousClub()
|
||||||
|
} else {
|
||||||
|
nextClub()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Haptic feedback
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to change club')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getClubIcon = (clubName: string) => {
|
||||||
|
// Return appropriate icon based on club type
|
||||||
|
if (clubName.includes('Driver')) return '🏌️'
|
||||||
|
if (clubName.includes('Wood')) return '🪵'
|
||||||
|
if (clubName.includes('Hybrid')) return '⚡'
|
||||||
|
if (clubName.includes('Iron')) return '🔧'
|
||||||
|
if (clubName.includes('Wedge') || clubName.includes('W')) return '⛳'
|
||||||
|
if (clubName.includes('Putter')) return '🎯'
|
||||||
|
return '🏌️'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getClubDistance = (clubName: string) => {
|
||||||
|
// Approximate distances for each club
|
||||||
|
const distances: { [key: string]: string } = {
|
||||||
|
'Driver': '250-280y',
|
||||||
|
'3 Wood': '215-240y',
|
||||||
|
'5 Wood': '200-220y',
|
||||||
|
'3 Hybrid': '190-210y',
|
||||||
|
'4 Iron': '180-200y',
|
||||||
|
'5 Iron': '170-190y',
|
||||||
|
'6 Iron': '160-180y',
|
||||||
|
'7 Iron': '150-170y',
|
||||||
|
'8 Iron': '140-160y',
|
||||||
|
'9 Iron': '130-150y',
|
||||||
|
'PW': '120-140y',
|
||||||
|
'GW': '100-120y',
|
||||||
|
'SW': '80-100y',
|
||||||
|
'LW': '60-80y',
|
||||||
|
'Putter': 'Green',
|
||||||
|
}
|
||||||
|
return distances[clubName] || '---'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Compact version for mobile
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => handleClubChange('up')}
|
||||||
|
className="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
aria-label="Previous Club"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 mx-3 text-center">
|
||||||
|
<div className="text-lg font-bold text-white">{club.current}</div>
|
||||||
|
<div className="text-xs text-gray-400">{getClubDistance(club.current)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleClubChange('down')}
|
||||||
|
className="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
aria-label="Next Club"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full version for desktop/tablet
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-xl shadow-2xl overflow-hidden h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-primary-600 to-primary-700 p-4">
|
||||||
|
<h3 className="text-white font-bold text-lg">Club Selection</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Club Display */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-gray-900 rounded-lg p-6 text-center">
|
||||||
|
<div className="text-4xl mb-2">{getClubIcon(club.current)}</div>
|
||||||
|
<div className="text-2xl font-bold text-white mb-1">{club.current}</div>
|
||||||
|
<div className="text-sm text-gray-400">{getClubDistance(club.current)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Club Change Buttons */}
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleClubChange('up')}
|
||||||
|
className="w-full flex items-center justify-center p-3 bg-gray-700 hover:bg-gray-600 rounded-lg transition-all group"
|
||||||
|
aria-label="Previous Club"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-300 group-hover:text-white mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-300 group-hover:text-white font-medium">Shorter Club</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleClubChange('down')}
|
||||||
|
className="w-full flex items-center justify-center p-3 bg-gray-700 hover:bg-gray-600 rounded-lg transition-all group"
|
||||||
|
aria-label="Next Club"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-300 group-hover:text-white mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-300 group-hover:text-white font-medium">Longer Club</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Club List Preview */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="text-xs text-gray-500 uppercase tracking-wider mb-2">Club Bag</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
club.index > 0 ? `↑ ${['Driver', '3 Wood', '5 Wood', '3 Hybrid', '4 Iron', '5 Iron', '6 Iron', '7 Iron', '8 Iron', '9 Iron', 'PW', 'GW', 'SW', 'LW', 'Putter'][club.index - 1]}` : '',
|
||||||
|
`→ ${club.current}`,
|
||||||
|
club.index < 14 ? `↓ ${['Driver', '3 Wood', '5 Wood', '3 Hybrid', '4 Iron', '5 Iron', '6 Iron', '7 Iron', '8 Iron', '9 Iron', 'PW', 'GW', 'SW', 'LW', 'Putter'][club.index + 1]}` : '',
|
||||||
|
].filter(Boolean).map((text, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`text-sm px-2 py-1 rounded ${
|
||||||
|
text.startsWith('→')
|
||||||
|
? 'bg-primary-600 bg-opacity-20 text-primary-400 font-medium'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClubIndicator
|
||||||
125
frontend/src/components/ConnectionStatus.tsx
Normal file
125
frontend/src/components/ConnectionStatus.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
import { getSystemHealth, formatUptime } from '../api/system'
|
||||||
|
|
||||||
|
const ConnectionStatus: React.FC = () => {
|
||||||
|
const { connectionStatus, gsproStatus } = useStore()
|
||||||
|
const [health, setHealth] = useState<any>(null)
|
||||||
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHealth = async () => {
|
||||||
|
const healthData = await getSystemHealth()
|
||||||
|
if (healthData) {
|
||||||
|
setHealth(healthData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchHealth()
|
||||||
|
const interval = setInterval(fetchHealth, 10000) // Update every 10 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-hide the status bar after 5 seconds if everything is connected
|
||||||
|
if (connectionStatus === 'connected' && gsproStatus === 'running') {
|
||||||
|
const timer = setTimeout(() => setIsVisible(false), 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
} else {
|
||||||
|
setIsVisible(true)
|
||||||
|
}
|
||||||
|
}, [connectionStatus, gsproStatus])
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (connectionStatus !== 'connected') return 'bg-red-600'
|
||||||
|
if (gsproStatus !== 'running') return 'bg-yellow-600'
|
||||||
|
return 'bg-green-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (connectionStatus !== 'connected') return 'Backend Disconnected'
|
||||||
|
if (gsproStatus !== 'running') return 'GSPro Not Running'
|
||||||
|
return 'Connected'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVisible && connectionStatus === 'connected' && gsproStatus === 'running') {
|
||||||
|
// Show a minimal indicator when hidden
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed top-2 right-2 z-50"
|
||||||
|
onClick={() => setIsVisible(true)}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-800 rounded-full p-2 cursor-pointer hover:bg-gray-700 transition-colors">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed top-0 left-0 right-0 z-50 transition-transform duration-300 ${isVisible ? 'translate-y-0' : '-translate-y-full'}`}>
|
||||||
|
<div className={`${getStatusColor()} bg-opacity-90 backdrop-blur-sm`}>
|
||||||
|
<div className="container mx-auto px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between text-white text-sm">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${connectionStatus === 'connected' ? 'bg-white animate-pulse' : 'bg-white opacity-50'}`}></div>
|
||||||
|
<span className="font-medium">{getStatusText()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backend status */}
|
||||||
|
{connectionStatus === 'connected' && (
|
||||||
|
<>
|
||||||
|
<div className="hidden sm:flex items-center space-x-1 text-xs opacity-80">
|
||||||
|
<span>Backend:</span>
|
||||||
|
<span className="font-mono">localhost:5005</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
{health && (
|
||||||
|
<div className="hidden md:flex items-center space-x-1 text-xs opacity-80">
|
||||||
|
<span>Uptime:</span>
|
||||||
|
<span className="font-mono">{formatUptime(health.uptime_seconds)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* GSPro status */}
|
||||||
|
{connectionStatus === 'connected' && (
|
||||||
|
<div className={`flex items-center space-x-1 text-xs px-2 py-1 rounded ${
|
||||||
|
gsproStatus === 'running'
|
||||||
|
? 'bg-white bg-opacity-20'
|
||||||
|
: 'bg-yellow-500 bg-opacity-50'
|
||||||
|
}`}>
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
|
||||||
|
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{gsproStatus === 'running' ? 'GSPro Ready' : 'GSPro Not Found'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close button for mobile */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVisible(false)}
|
||||||
|
className="sm:hidden p-1 hover:bg-white hover:bg-opacity-20 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionStatus
|
||||||
106
frontend/src/components/ErrorBoundary.tsx
Normal file
106
frontend/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
errorInfo: ErrorInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error, errorInfo: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo)
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
})
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-gray-800 rounded-lg shadow-xl p-6 max-w-lg w-full">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className="bg-red-600 rounded-full p-3 mr-4">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-white">Something went wrong</h2>
|
||||||
|
<p className="text-gray-400">An unexpected error occurred</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{this.state.error && (
|
||||||
|
<div className="bg-gray-900 rounded p-3 mb-4">
|
||||||
|
<p className="text-sm font-mono text-red-400">{this.state.error.toString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
|
||||||
|
<details className="mb-4">
|
||||||
|
<summary className="cursor-pointer text-sm text-gray-400 hover:text-gray-300">
|
||||||
|
Show details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 text-xs bg-gray-900 p-3 rounded overflow-auto max-h-64 text-gray-400">
|
||||||
|
{this.state.errorInfo.componentStack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="flex-1 bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Reload App
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary
|
||||||
284
frontend/src/components/MapPanel.tsx
Normal file
284
frontend/src/components/MapPanel.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
import { StreamingClient } from '../api/client'
|
||||||
|
import { actionsAPI } from '../api/client'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
interface MapPanelProps {
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MapPanel: React.FC<MapPanelProps> = ({ compact = false }) => {
|
||||||
|
const { mapConfig, toggleMapExpanded, setMapStreaming, settings } = useStore()
|
||||||
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const streamClientRef = useRef<StreamingClient | null>(null)
|
||||||
|
const animationFrameRef = useRef<number>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize streaming client
|
||||||
|
streamClientRef.current = new StreamingClient(
|
||||||
|
(data) => {
|
||||||
|
// Handle incoming frame
|
||||||
|
if (data.data && canvasRef.current) {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Clear and draw new frame
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
img.src = `data:image/jpeg;base64,${data.data}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Stream error:', error)
|
||||||
|
toast.error('Stream connection lost')
|
||||||
|
setIsConnected(false)
|
||||||
|
setMapStreaming(false)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log('Stream connected')
|
||||||
|
setIsConnected(true)
|
||||||
|
setMapStreaming(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log('Stream disconnected')
|
||||||
|
setIsConnected(false)
|
||||||
|
setMapStreaming(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup
|
||||||
|
streamClientRef.current?.disconnect()
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setMapStreaming])
|
||||||
|
|
||||||
|
const handleStartStream = () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
const config = {
|
||||||
|
fps: settings.streamFPS,
|
||||||
|
quality: settings.streamQuality === 'high' ? 90 : settings.streamQuality === 'medium' ? 75 : 60,
|
||||||
|
resolution: settings.streamQuality === 'high' ? '1080p' : settings.streamQuality === 'medium' ? '720p' : '480p',
|
||||||
|
region_x: mapConfig.x,
|
||||||
|
region_y: mapConfig.y,
|
||||||
|
region_width: mapConfig.width,
|
||||||
|
region_height: mapConfig.height,
|
||||||
|
}
|
||||||
|
streamClientRef.current?.connect(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStopStream = () => {
|
||||||
|
streamClientRef.current?.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleMap = async () => {
|
||||||
|
try {
|
||||||
|
await actionsAPI.sendKey('s')
|
||||||
|
toggleMapExpanded()
|
||||||
|
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to toggle map')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleZoom = async (direction: 'in' | 'out') => {
|
||||||
|
try {
|
||||||
|
const key = direction === 'in' ? 'q' : 'w'
|
||||||
|
await actionsAPI.sendKey(key)
|
||||||
|
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to zoom map')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Compact version for mobile
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-300">Map</span>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleMap}
|
||||||
|
className="text-xs text-primary-400 hover:text-primary-300"
|
||||||
|
>
|
||||||
|
{mapConfig.expanded ? 'Collapse' : 'Expand'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative bg-black rounded-lg overflow-hidden" style={{ height: '120px' }}>
|
||||||
|
{!isConnected ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleStartStream}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-3 py-1 bg-primary-600 hover:bg-primary-700 text-white text-sm rounded transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Connecting...' : 'Start Stream'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
width={320}
|
||||||
|
height={120}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleStopStream}
|
||||||
|
className="absolute top-1 right-1 p-1 bg-gray-900 bg-opacity-50 rounded hover:bg-opacity-70 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full version for desktop/tablet
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800 rounded-xl shadow-2xl overflow-hidden ${mapConfig.expanded ? 'fixed inset-4 z-50' : 'h-full'}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-green-600 to-green-700 p-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-white font-bold text-lg">Course Map</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{isConnected && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleZoom('in')}
|
||||||
|
className="p-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded transition-colors"
|
||||||
|
aria-label="Zoom In"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleZoom('out')}
|
||||||
|
className="p-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded transition-colors"
|
||||||
|
aria-label="Zoom Out"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleToggleMap}
|
||||||
|
className="p-1 bg-white bg-opacity-20 hover:bg-opacity-30 rounded transition-colors"
|
||||||
|
aria-label={mapConfig.expanded ? 'Collapse Map' : 'Expand Map'}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
{mapConfig.expanded ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Display */}
|
||||||
|
<div className={`relative bg-black ${mapConfig.expanded ? 'h-[calc(100%-60px)]' : 'h-64'}`}>
|
||||||
|
{!isConnected ? (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
|
</svg>
|
||||||
|
<h4 className="text-white font-medium mb-2">Map Stream</h4>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">View the GSPro course map in real-time</p>
|
||||||
|
<button
|
||||||
|
onClick={handleStartStream}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Connecting...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Start Streaming'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="w-full h-full"
|
||||||
|
width={mapConfig.expanded ? 1280 : 640}
|
||||||
|
height={mapConfig.expanded ? 720 : 480}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stream Controls Overlay */}
|
||||||
|
<div className="absolute top-2 right-2 flex items-center space-x-2">
|
||||||
|
<div className="px-2 py-1 bg-green-600 bg-opacity-75 rounded text-xs text-white flex items-center">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full animate-pulse mr-1"></div>
|
||||||
|
Live
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleStopStream}
|
||||||
|
className="p-1 bg-gray-900 bg-opacity-50 hover:bg-opacity-70 rounded transition-colors"
|
||||||
|
aria-label="Stop Stream"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Click Overlay (when expanded) */}
|
||||||
|
{mapConfig.expanded && (
|
||||||
|
<div className="absolute bottom-4 left-4 bg-gray-900 bg-opacity-75 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-gray-300">Click on the map to set target location</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stream Info */}
|
||||||
|
{isConnected && !mapConfig.expanded && (
|
||||||
|
<div className="p-3 border-t border-gray-700">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||||
|
<span>Quality: {settings.streamQuality}</span>
|
||||||
|
<span>FPS: {settings.streamFPS}</span>
|
||||||
|
<span>Resolution: {settings.streamQuality === 'high' ? '1080p' : settings.streamQuality === 'medium' ? '720p' : '480p'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapPanel
|
||||||
205
frontend/src/components/QuickActions.tsx
Normal file
205
frontend/src/components/QuickActions.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { actionsAPI } from '../api/client'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
|
||||||
|
interface QuickActionsProps {
|
||||||
|
mobile?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuickActions: React.FC<QuickActionsProps> = ({ mobile = false }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const { settings } = useStore()
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
id: 'scorecard',
|
||||||
|
label: 'Scorecard',
|
||||||
|
icon: '📊',
|
||||||
|
key: 't',
|
||||||
|
description: 'Toggle scorecard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rangefinder',
|
||||||
|
label: 'Range Finder',
|
||||||
|
icon: '🎯',
|
||||||
|
key: 'r',
|
||||||
|
description: 'Launch range finder',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'heatmap',
|
||||||
|
label: 'Heat Map',
|
||||||
|
icon: '🗺️',
|
||||||
|
key: 'y',
|
||||||
|
description: 'Toggle heat map',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'flyover',
|
||||||
|
label: 'Flyover',
|
||||||
|
icon: '🚁',
|
||||||
|
key: 'o',
|
||||||
|
description: 'Hole preview',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pin',
|
||||||
|
label: 'Pin Indicator',
|
||||||
|
icon: '🚩',
|
||||||
|
key: 'p',
|
||||||
|
description: 'Show/hide pin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'freelook',
|
||||||
|
label: 'Free Look',
|
||||||
|
icon: '👀',
|
||||||
|
key: 'f5',
|
||||||
|
description: 'Unlock camera',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'aimpoint',
|
||||||
|
label: 'Aim Point',
|
||||||
|
icon: '🎪',
|
||||||
|
key: 'f3',
|
||||||
|
description: 'Toggle aim point',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tracerclear',
|
||||||
|
label: 'Clear Tracer',
|
||||||
|
icon: '💨',
|
||||||
|
key: 'f1',
|
||||||
|
description: 'Clear ball tracer',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleAction = async (action: typeof actions[0]) => {
|
||||||
|
try {
|
||||||
|
await actionsAPI.sendKey(action.key)
|
||||||
|
toast.success(action.description)
|
||||||
|
setIsOpen(false)
|
||||||
|
|
||||||
|
// Haptic feedback
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to execute ${action.label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
setIsOpen(!isOpen)
|
||||||
|
|
||||||
|
// Haptic feedback
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
|
// Mobile version - bottom sheet style
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Action Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`fixed bottom-20 right-4 z-40 w-14 h-14 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all transform ${
|
||||||
|
isOpen ? 'rotate-45 scale-110' : ''
|
||||||
|
}`}
|
||||||
|
aria-label="Quick Actions"
|
||||||
|
>
|
||||||
|
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bottom Sheet */}
|
||||||
|
<div
|
||||||
|
className={`fixed inset-x-0 bottom-0 z-30 bg-gray-800 rounded-t-2xl shadow-2xl transition-transform duration-300 ${
|
||||||
|
isOpen ? 'translate-y-0' : 'translate-y-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="w-12 h-1 bg-gray-600 rounded-full mx-auto mb-4"></div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-3">Quick Actions</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 p-3 rounded-lg text-center transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-1">{action.icon}</div>
|
||||||
|
<div className="text-xs text-gray-300">{action.label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-20"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop version - floating menu
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-8 right-8 z-40">
|
||||||
|
{/* Action Menu */}
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-16 right-0 bg-gray-800 rounded-lg shadow-2xl overflow-hidden transition-all duration-300 ${
|
||||||
|
isOpen
|
||||||
|
? 'opacity-100 scale-100 translate-y-0'
|
||||||
|
: 'opacity-0 scale-95 translate-y-4 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-2 max-h-96 overflow-y-auto">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
className="w-full flex items-center space-x-3 px-3 py-2 hover:bg-gray-700 rounded-lg transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="text-xl">{action.icon}</span>
|
||||||
|
<div className="text-left flex-1">
|
||||||
|
<div className="text-sm font-medium text-white group-hover:text-primary-400 transition-colors">
|
||||||
|
{action.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">{action.description}</div>
|
||||||
|
</div>
|
||||||
|
<kbd className="hidden sm:inline-block px-2 py-1 text-xs bg-gray-900 rounded">
|
||||||
|
{action.key.toUpperCase()}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Action Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`relative w-14 h-14 bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white rounded-full shadow-lg transition-all transform hover:scale-110 ${
|
||||||
|
isOpen ? 'rotate-45' : ''
|
||||||
|
}`}
|
||||||
|
aria-label="Quick Actions"
|
||||||
|
>
|
||||||
|
<svg className="w-8 h-8 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Pulse animation when closed */}
|
||||||
|
{!isOpen && (
|
||||||
|
<span className="absolute inset-0 rounded-full bg-primary-600 animate-ping opacity-30"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuickActions
|
||||||
130
frontend/src/components/ShotOptions.tsx
Normal file
130
frontend/src/components/ShotOptions.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
import { actionsAPI } from '../api/client'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
interface ShotOptionsProps {
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShotOptions: React.FC<ShotOptionsProps> = ({ compact = false }) => {
|
||||||
|
const { shotMode, setShotMode, settings } = useStore()
|
||||||
|
|
||||||
|
const shotTypes = [
|
||||||
|
{ id: 'normal', label: 'Normal', icon: '⚫', key: null },
|
||||||
|
{ id: 'punch', label: 'Punch', icon: '👊', key: "'" },
|
||||||
|
{ id: 'flop', label: 'Flop', icon: '🎯', key: "'" },
|
||||||
|
{ id: 'chip', label: 'Chip', icon: '⛳', key: "'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleShotChange = async (mode: 'normal' | 'punch' | 'flop' | 'chip') => {
|
||||||
|
try {
|
||||||
|
// Send key if needed (shot options typically cycle with apostrophe key)
|
||||||
|
if (mode !== 'normal' && mode !== shotMode) {
|
||||||
|
await actionsAPI.sendKey("'")
|
||||||
|
}
|
||||||
|
|
||||||
|
setShotMode(mode)
|
||||||
|
|
||||||
|
// Haptic feedback
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Shot mode: ${mode}`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to change shot mode')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Compact horizontal layout for mobile
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider mb-2">Shot Type</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{shotTypes.map((shot) => (
|
||||||
|
<button
|
||||||
|
key={shot.id}
|
||||||
|
onClick={() => handleShotChange(shot.id as any)}
|
||||||
|
className={`flex-1 px-2 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
shotMode === shot.id
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-lg mb-1">{shot.icon}</span>
|
||||||
|
<span className="block text-xs">{shot.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full version for desktop/tablet
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-xl shadow-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3">Shot Options</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{shotTypes.map((shot) => (
|
||||||
|
<button
|
||||||
|
key={shot.id}
|
||||||
|
onClick={() => handleShotChange(shot.id as any)}
|
||||||
|
className={`relative p-3 rounded-lg transition-all transform hover:scale-105 ${
|
||||||
|
shotMode === shot.id
|
||||||
|
? 'bg-gradient-to-r from-primary-600 to-primary-700 text-white shadow-lg'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-2xl mb-1">{shot.icon}</span>
|
||||||
|
<span className="text-sm font-medium">{shot.label}</span>
|
||||||
|
</div>
|
||||||
|
{shotMode === shot.id && (
|
||||||
|
<div className="absolute top-1 right-1">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional shot controls */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await actionsAPI.sendKey('u')
|
||||||
|
toast.success('Putt mode toggled')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to toggle putt mode')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
🎯 Putt Toggle
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await actionsAPI.sendKey('g')
|
||||||
|
toast.success('Green grid toggled')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to toggle green grid')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
📋 Green Grid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShotOptions
|
||||||
164
frontend/src/components/StatBar.tsx
Normal file
164
frontend/src/components/StatBar.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { systemAPI } from '../api/client'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
|
||||||
|
interface StatBarProps {
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
windSpeed: number
|
||||||
|
windDirection: string
|
||||||
|
distance: number
|
||||||
|
elevation: number
|
||||||
|
lie: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatBar: React.FC<StatBarProps> = ({ compact = false }) => {
|
||||||
|
const { club, shotMode } = useStore()
|
||||||
|
const [stats, setStats] = useState<Stats>({
|
||||||
|
windSpeed: 0,
|
||||||
|
windDirection: 'N',
|
||||||
|
distance: 0,
|
||||||
|
elevation: 0,
|
||||||
|
lie: 'Fairway',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// In a real implementation, these would come from the backend
|
||||||
|
// For now, using placeholder values
|
||||||
|
setStats({
|
||||||
|
windSpeed: Math.floor(Math.random() * 20),
|
||||||
|
windDirection: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)],
|
||||||
|
distance: 150,
|
||||||
|
elevation: Math.floor(Math.random() * 20) - 10,
|
||||||
|
lie: 'Fairway',
|
||||||
|
})
|
||||||
|
}, [club])
|
||||||
|
|
||||||
|
const getWindArrow = (direction: string) => {
|
||||||
|
const arrows: { [key: string]: string } = {
|
||||||
|
'N': '↑',
|
||||||
|
'NE': '↗',
|
||||||
|
'E': '→',
|
||||||
|
'SE': '↘',
|
||||||
|
'S': '↓',
|
||||||
|
'SW': '↙',
|
||||||
|
'W': '←',
|
||||||
|
'NW': '↖',
|
||||||
|
}
|
||||||
|
return arrows[direction] || '↑'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLieColor = (lie: string) => {
|
||||||
|
switch (lie.toLowerCase()) {
|
||||||
|
case 'fairway':
|
||||||
|
return 'text-green-400'
|
||||||
|
case 'rough':
|
||||||
|
return 'text-yellow-400'
|
||||||
|
case 'sand':
|
||||||
|
return 'text-orange-400'
|
||||||
|
case 'water':
|
||||||
|
return 'text-blue-400'
|
||||||
|
default:
|
||||||
|
return 'text-gray-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Compact version for mobile
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-2 shadow-lg">
|
||||||
|
<div className="flex items-center justify-around text-xs">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span className="text-gray-500">Wind:</span>
|
||||||
|
<span className="text-cyan-400 font-medium">{stats.windSpeed}mph</span>
|
||||||
|
<span className="text-cyan-400">{getWindArrow(stats.windDirection)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span className="text-gray-500">Dist:</span>
|
||||||
|
<span className="text-white font-medium">{stats.distance}y</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span className="text-gray-500">Elev:</span>
|
||||||
|
<span className={`font-medium ${stats.elevation > 0 ? 'text-red-400' : stats.elevation < 0 ? 'text-blue-400' : 'text-gray-400'}`}>
|
||||||
|
{stats.elevation > 0 ? '+' : ''}{stats.elevation}ft
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span className="text-gray-500">Lie:</span>
|
||||||
|
<span className={`font-medium ${getLieColor(stats.lie)}`}>{stats.lie}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full version for desktop/tablet
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-r from-gray-800 to-gray-900 rounded-xl shadow-2xl p-4">
|
||||||
|
<div className="grid grid-cols-5 gap-4">
|
||||||
|
{/* Wind */}
|
||||||
|
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Wind</div>
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<span className="text-2xl text-cyan-400">{getWindArrow(stats.windDirection)}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-cyan-400">{stats.windSpeed}</div>
|
||||||
|
<div className="text-xs text-gray-500">mph</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distance to Pin */}
|
||||||
|
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">To Pin</div>
|
||||||
|
<div className="text-2xl font-bold text-white">{stats.distance}</div>
|
||||||
|
<div className="text-xs text-gray-500">yards</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Elevation */}
|
||||||
|
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Elevation</div>
|
||||||
|
<div className={`text-2xl font-bold ${stats.elevation > 0 ? 'text-red-400' : stats.elevation < 0 ? 'text-blue-400' : 'text-gray-400'}`}>
|
||||||
|
{stats.elevation > 0 ? '+' : ''}{stats.elevation}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">feet</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lie */}
|
||||||
|
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Lie</div>
|
||||||
|
<div className={`text-lg font-bold ${getLieColor(stats.lie)}`}>{stats.lie}</div>
|
||||||
|
<div className="text-xs text-gray-500">{shotMode} shot</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Club */}
|
||||||
|
<div className="bg-gray-900 bg-opacity-50 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider mb-1">Club</div>
|
||||||
|
<div className="text-lg font-bold text-primary-400">{club.current}</div>
|
||||||
|
<div className="text-xs text-gray-500">Selected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional info bar */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span>Hole: 1 • Par 4</span>
|
||||||
|
<span>Stroke: 1</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span>Round Time: 0:00</span>
|
||||||
|
<span>Score: E</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatBar
|
||||||
110
frontend/src/components/TeeControls.tsx
Normal file
110
frontend/src/components/TeeControls.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { actionsAPI } from '../api/client'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
interface TeeControlsProps {
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeeControls: React.FC<TeeControlsProps> = ({ compact = false }) => {
|
||||||
|
const { settings } = useStore()
|
||||||
|
|
||||||
|
const handleTeeMove = async (direction: 'left' | 'right') => {
|
||||||
|
try {
|
||||||
|
const key = direction === 'left' ? 'c' : 'v'
|
||||||
|
await actionsAPI.sendKey(key)
|
||||||
|
|
||||||
|
// Haptic feedback
|
||||||
|
if (settings.hapticFeedback && 'vibrate' in navigator) {
|
||||||
|
navigator.vibrate(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Tee moved ${direction}`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to move tee')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Compact version for mobile
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 shadow-lg">
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider mb-2 text-center">Tee Box</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTeeMove('left')}
|
||||||
|
className="flex-1 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition-colors flex items-center justify-center"
|
||||||
|
aria-label="Move Tee Left"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">Left</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center px-3">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleTeeMove('right')}
|
||||||
|
className="flex-1 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition-colors flex items-center justify-center"
|
||||||
|
aria-label="Move Tee Right"
|
||||||
|
>
|
||||||
|
<span className="text-sm">Right</span>
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full version for desktop/tablet
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-xl shadow-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 uppercase tracking-wider mb-3 text-center">Tee Position</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTeeMove('left')}
|
||||||
|
className="flex-1 px-4 py-3 bg-gradient-to-r from-gray-700 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white rounded-lg transition-all transform hover:scale-105 flex items-center justify-center group"
|
||||||
|
aria-label="Move Tee Left"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">Move Left</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-full flex items-center justify-center shadow-lg">
|
||||||
|
<svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-12 h-1 bg-green-700 rounded-full opacity-50"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleTeeMove('right')}
|
||||||
|
className="flex-1 px-4 py-3 bg-gradient-to-r from-gray-700 to-gray-600 hover:from-gray-600 hover:to-gray-500 text-white rounded-lg transition-all transform hover:scale-105 flex items-center justify-center group"
|
||||||
|
aria-label="Move Tee Right"
|
||||||
|
>
|
||||||
|
<span className="font-medium">Move Right</span>
|
||||||
|
<svg className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">Adjust your starting position on the tee box</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeeControls
|
||||||
361
frontend/src/index.css
Normal file
361
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Custom global styles */
|
||||||
|
@layer base {
|
||||||
|
/* Set default font and colors */
|
||||||
|
html {
|
||||||
|
@apply font-sans antialiased;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styles */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
@apply w-2 h-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-gray-800 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-600 rounded-full hover:bg-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #4b5563 #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Viewport height fix for mobile */
|
||||||
|
.h-screen-safe {
|
||||||
|
height: 100vh;
|
||||||
|
height: calc(var(--vh, 1vh) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection on interactive elements */
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.interactive {
|
||||||
|
@apply select-none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Button base styles */
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center px-4 py-2 font-medium rounded-lg transition-all duration-200 focus-ring disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 active:bg-secondary-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
@apply btn bg-success-600 text-white hover:bg-success-700 active:bg-success-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply btn bg-error-600 text-white hover:bg-error-700 active:bg-error-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn bg-transparent hover:bg-gray-800 active:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
@apply btn border-2 border-gray-600 hover:bg-gray-800 active:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button */
|
||||||
|
.btn-icon {
|
||||||
|
@apply inline-flex items-center justify-center p-2 rounded-lg transition-all duration-200 focus-ring hover:bg-gray-800 active:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card component */
|
||||||
|
.card {
|
||||||
|
@apply bg-gray-800 rounded-xl border border-gray-700 shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
@apply p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input styles */
|
||||||
|
.input {
|
||||||
|
@apply block w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label styles */
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-gray-300 mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directional pad button */
|
||||||
|
.dpad-btn {
|
||||||
|
@apply flex items-center justify-center bg-gray-700 hover:bg-gray-600 active:bg-primary-600 transition-all duration-150 select-none cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator */
|
||||||
|
.status-dot {
|
||||||
|
@apply inline-block w-2 h-2 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
@apply bg-success-500 animate-pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.offline {
|
||||||
|
@apply bg-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error {
|
||||||
|
@apply bg-error-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Golf-specific styles */
|
||||||
|
.golf-green {
|
||||||
|
@apply bg-gradient-to-br from-green-700 to-green-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.golf-fairway {
|
||||||
|
@apply bg-gradient-to-br from-green-600 to-green-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.golf-sand {
|
||||||
|
@apply bg-gradient-to-br from-yellow-600 to-yellow-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.golf-water {
|
||||||
|
@apply bg-gradient-to-br from-blue-600 to-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aim pad grid */
|
||||||
|
.aim-grid {
|
||||||
|
@apply grid grid-cols-3 gap-1 w-48 h-48;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map panel */
|
||||||
|
.map-panel {
|
||||||
|
@apply relative bg-black rounded-lg overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-panel.expanded {
|
||||||
|
@apply fixed inset-4 z-50 md:inset-8 lg:inset-16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat display */
|
||||||
|
.stat-item {
|
||||||
|
@apply flex flex-col items-center p-2 bg-gray-800 rounded-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
@apply text-xl font-bold text-primary-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
@apply text-xs text-gray-400 uppercase tracking-wider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle {
|
||||||
|
@apply relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.checked {
|
||||||
|
@apply bg-primary-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:not(.checked) {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
@apply inline-block h-4 w-4 transform rounded-full bg-white transition-transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.checked .toggle-thumb {
|
||||||
|
@apply translate-x-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:not(.checked) .toggle-thumb {
|
||||||
|
@apply translate-x-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Touch action utilities */
|
||||||
|
.touch-none {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-pan-x {
|
||||||
|
touch-action: pan-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-pan-y {
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent double-tap zoom on mobile */
|
||||||
|
.no-tap-zoom {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass morphism effect */
|
||||||
|
.glass {
|
||||||
|
@apply bg-gray-900 bg-opacity-60 backdrop-blur-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
@apply bg-gradient-to-r from-primary-400 to-success-400 bg-clip-text text-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading animation */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -1000px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 1000px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
animation: shimmer 2s linear infinite;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.1) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
background-size: 1000px 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade animations */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-out {
|
||||||
|
animation: fadeOut 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide animations */
|
||||||
|
.slide-in-left {
|
||||||
|
animation: slideInLeft 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-right {
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-up {
|
||||||
|
animation: slideInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in-down {
|
||||||
|
animation: slideInDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive text utilities */
|
||||||
|
.text-responsive {
|
||||||
|
@apply text-sm sm:text-base lg:text-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-responsive {
|
||||||
|
@apply text-2xl sm:text-3xl lg:text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom grid utilities */
|
||||||
|
.grid-center {
|
||||||
|
@apply grid place-items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aspect ratio utilities */
|
||||||
|
.aspect-map {
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay utilities */
|
||||||
|
.overlay {
|
||||||
|
@apply fixed inset-0 bg-black bg-opacity-50 z-40;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/main.tsx
Normal file
23
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
// Create root element
|
||||||
|
const rootElement = document.getElementById('root')
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Failed to find the root element')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create React root
|
||||||
|
const root = ReactDOM.createRoot(rootElement)
|
||||||
|
|
||||||
|
// Render the app
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signal that the app is ready (removes loading screen)
|
||||||
|
window.dispatchEvent(new Event('app-ready'))
|
||||||
202
frontend/src/pages/DynamicGolfUI.tsx
Normal file
202
frontend/src/pages/DynamicGolfUI.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useStore } from '../stores/appStore'
|
||||||
|
import AimPad from '../components/AimPad'
|
||||||
|
import ClubIndicator from '../components/ClubIndicator'
|
||||||
|
import MapPanel from '../components/MapPanel'
|
||||||
|
import ShotOptions from '../components/ShotOptions'
|
||||||
|
import TeeControls from '../components/TeeControls'
|
||||||
|
import StatBar from '../components/StatBar'
|
||||||
|
import QuickActions from '../components/QuickActions'
|
||||||
|
import { actionsAPI, systemAPI } from '../api/client'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
const DynamicGolfUI: React.FC = () => {
|
||||||
|
const {
|
||||||
|
club,
|
||||||
|
mapConfig,
|
||||||
|
shotMode,
|
||||||
|
mulliganActive,
|
||||||
|
setGSProStatus,
|
||||||
|
toggleMulligan,
|
||||||
|
toggleMapExpanded,
|
||||||
|
} = useStore()
|
||||||
|
|
||||||
|
const [isLandscape, setIsLandscape] = useState(
|
||||||
|
window.innerWidth > window.innerHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check GSPro status
|
||||||
|
const checkGSPro = async () => {
|
||||||
|
try {
|
||||||
|
const status = await systemAPI.getGSProStatus()
|
||||||
|
setGSProStatus(status.running ? 'running' : 'not_running')
|
||||||
|
} catch (error) {
|
||||||
|
setGSProStatus('unknown')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkGSPro()
|
||||||
|
const interval = setInterval(checkGSPro, 5000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [setGSProStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle orientation change
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsLandscape(window.innerWidth > window.innerHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
window.addEventListener('orientationchange', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
window.removeEventListener('orientationchange', handleResize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMulligan = async () => {
|
||||||
|
try {
|
||||||
|
await actionsAPI.sendKey('ctrl+m')
|
||||||
|
toggleMulligan()
|
||||||
|
toast.success(mulliganActive ? 'Mulligan disabled' : 'Mulligan enabled')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to toggle mulligan')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
await actionsAPI.sendKey('a')
|
||||||
|
toast.success('Reset')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to reset')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render different layouts based on orientation
|
||||||
|
if (isLandscape) {
|
||||||
|
// Landscape layout (tablets, desktop)
|
||||||
|
return (
|
||||||
|
<div className="h-screen-safe bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
||||||
|
<div className="h-full flex flex-col p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<h1 className="text-2xl font-bold text-white">GSPro Remote</h1>
|
||||||
|
<span className="text-sm text-gray-400">v0.1.0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={handleMulligan}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||||
|
mulliganActive
|
||||||
|
? 'bg-yellow-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mulligan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-4 py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 transition-all"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex gap-4">
|
||||||
|
{/* Left panel - Club */}
|
||||||
|
<div className="w-48">
|
||||||
|
<ClubIndicator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center - Aim Pad */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center">
|
||||||
|
<AimPad />
|
||||||
|
<div className="mt-6">
|
||||||
|
<TeeControls />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel - Map */}
|
||||||
|
<div className="w-80">
|
||||||
|
<MapPanel />
|
||||||
|
<div className="mt-4">
|
||||||
|
<ShotOptions />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<StatBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions floating button */}
|
||||||
|
<QuickActions />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Portrait layout (phones)
|
||||||
|
return (
|
||||||
|
<div className="h-screen-safe bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
||||||
|
<div className="h-full flex flex-col p-3">
|
||||||
|
{/* Header - Compact */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h1 className="text-lg font-bold text-white">GSPro Remote</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleMulligan}
|
||||||
|
className={`px-3 py-1 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
mulliganActive
|
||||||
|
? 'bg-yellow-600 text-white'
|
||||||
|
: 'bg-gray-700 text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mulligan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex-1 flex flex-col space-y-3">
|
||||||
|
{/* Top row - Club and Map */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<ClubIndicator compact />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<MapPanel compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center - Aim Pad */}
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<AimPad size="medium" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom controls */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<TeeControls compact />
|
||||||
|
<ShotOptions compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom stats */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<StatBar compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions for mobile */}
|
||||||
|
<QuickActions mobile />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicGolfUI
|
||||||
229
frontend/src/stores/appStore.ts
Normal file
229
frontend/src/stores/appStore.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { devtools, persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
export interface ClubInfo {
|
||||||
|
current: string
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapConfig {
|
||||||
|
expanded: boolean
|
||||||
|
zoom: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
hapticFeedback: boolean
|
||||||
|
soundEffects: boolean
|
||||||
|
streamQuality: 'low' | 'medium' | 'high'
|
||||||
|
streamFPS: number
|
||||||
|
showStats: boolean
|
||||||
|
compactMode: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
// Connection status
|
||||||
|
isConnected: boolean
|
||||||
|
connectionStatus: 'connected' | 'disconnected' | 'connecting'
|
||||||
|
gsproStatus: 'running' | 'not_running' | 'unknown'
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
currentView: 'main' | 'settings' | 'advanced'
|
||||||
|
isLoading: boolean
|
||||||
|
|
||||||
|
// Golf state
|
||||||
|
club: ClubInfo
|
||||||
|
aimDirection: { x: number; y: number }
|
||||||
|
mulliganActive: boolean
|
||||||
|
shotMode: 'normal' | 'punch' | 'flop' | 'chip'
|
||||||
|
|
||||||
|
// Map state
|
||||||
|
mapConfig: MapConfig
|
||||||
|
isMapStreaming: boolean
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: AppSettings
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setConnectionStatus: (connected: boolean) => void
|
||||||
|
setGSProStatus: (status: 'running' | 'not_running' | 'unknown') => void
|
||||||
|
setCurrentView: (view: 'main' | 'settings' | 'advanced') => void
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
|
||||||
|
// Golf actions
|
||||||
|
setClub: (club: ClubInfo) => void
|
||||||
|
nextClub: () => void
|
||||||
|
previousClub: () => void
|
||||||
|
setAimDirection: (direction: { x: number; y: number }) => void
|
||||||
|
toggleMulligan: () => void
|
||||||
|
setShotMode: (mode: 'normal' | 'punch' | 'flop' | 'chip') => void
|
||||||
|
|
||||||
|
// Map actions
|
||||||
|
toggleMapExpanded: () => void
|
||||||
|
setMapConfig: (config: Partial<MapConfig>) => void
|
||||||
|
setMapStreaming: (streaming: boolean) => void
|
||||||
|
|
||||||
|
// Settings actions
|
||||||
|
updateSettings: (settings: Partial<AppSettings>) => void
|
||||||
|
resetSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLUBS = [
|
||||||
|
'Driver',
|
||||||
|
'3 Wood',
|
||||||
|
'5 Wood',
|
||||||
|
'3 Hybrid',
|
||||||
|
'4 Iron',
|
||||||
|
'5 Iron',
|
||||||
|
'6 Iron',
|
||||||
|
'7 Iron',
|
||||||
|
'8 Iron',
|
||||||
|
'9 Iron',
|
||||||
|
'PW',
|
||||||
|
'GW',
|
||||||
|
'SW',
|
||||||
|
'LW',
|
||||||
|
'Putter'
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
hapticFeedback: true,
|
||||||
|
soundEffects: false,
|
||||||
|
streamQuality: 'medium',
|
||||||
|
streamFPS: 30,
|
||||||
|
showStats: true,
|
||||||
|
compactMode: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAP_CONFIG: MapConfig = {
|
||||||
|
expanded: false,
|
||||||
|
zoom: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<AppState>()(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
isConnected: false,
|
||||||
|
connectionStatus: 'disconnected',
|
||||||
|
gsproStatus: 'unknown',
|
||||||
|
currentView: 'main',
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
club: {
|
||||||
|
current: 'Driver',
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
aimDirection: { x: 0, y: 0 },
|
||||||
|
mulliganActive: false,
|
||||||
|
shotMode: 'normal',
|
||||||
|
|
||||||
|
mapConfig: DEFAULT_MAP_CONFIG,
|
||||||
|
isMapStreaming: false,
|
||||||
|
|
||||||
|
settings: DEFAULT_SETTINGS,
|
||||||
|
|
||||||
|
// Connection actions
|
||||||
|
setConnectionStatus: (connected) =>
|
||||||
|
set({
|
||||||
|
isConnected: connected,
|
||||||
|
connectionStatus: connected ? 'connected' : 'disconnected',
|
||||||
|
}),
|
||||||
|
|
||||||
|
setGSProStatus: (status) => set({ gsproStatus: status }),
|
||||||
|
|
||||||
|
// UI actions
|
||||||
|
setCurrentView: (view) => set({ currentView: view }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
// Golf actions
|
||||||
|
setClub: (club) => set({ club }),
|
||||||
|
|
||||||
|
nextClub: () => {
|
||||||
|
const state = get()
|
||||||
|
const nextIndex = (state.club.index + 1) % CLUBS.length
|
||||||
|
set({
|
||||||
|
club: {
|
||||||
|
current: CLUBS[nextIndex],
|
||||||
|
index: nextIndex,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
previousClub: () => {
|
||||||
|
const state = get()
|
||||||
|
const prevIndex = state.club.index === 0 ? CLUBS.length - 1 : state.club.index - 1
|
||||||
|
set({
|
||||||
|
club: {
|
||||||
|
current: CLUBS[prevIndex],
|
||||||
|
index: prevIndex,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setAimDirection: (direction) => set({ aimDirection: direction }),
|
||||||
|
|
||||||
|
toggleMulligan: () =>
|
||||||
|
set((state) => ({ mulliganActive: !state.mulliganActive })),
|
||||||
|
|
||||||
|
setShotMode: (mode) => set({ shotMode: mode }),
|
||||||
|
|
||||||
|
// Map actions
|
||||||
|
toggleMapExpanded: () =>
|
||||||
|
set((state) => ({
|
||||||
|
mapConfig: {
|
||||||
|
...state.mapConfig,
|
||||||
|
expanded: !state.mapConfig.expanded,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setMapConfig: (config) =>
|
||||||
|
set((state) => ({
|
||||||
|
mapConfig: {
|
||||||
|
...state.mapConfig,
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setMapStreaming: (streaming) => set({ isMapStreaming: streaming }),
|
||||||
|
|
||||||
|
// Settings actions
|
||||||
|
updateSettings: (settings) =>
|
||||||
|
set((state) => ({
|
||||||
|
settings: {
|
||||||
|
...state.settings,
|
||||||
|
...settings,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
resetSettings: () => set({ settings: DEFAULT_SETTINGS }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'gspro-remote-store',
|
||||||
|
partialize: (state) => ({
|
||||||
|
settings: state.settings,
|
||||||
|
mapConfig: state.mapConfig,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selectors
|
||||||
|
export const selectIsConnected = (state: AppState) => state.isConnected
|
||||||
|
export const selectConnectionStatus = (state: AppState) => state.connectionStatus
|
||||||
|
export const selectGSProStatus = (state: AppState) => state.gsproStatus
|
||||||
|
export const selectCurrentView = (state: AppState) => state.currentView
|
||||||
|
export const selectClub = (state: AppState) => state.club
|
||||||
|
export const selectMapConfig = (state: AppState) => state.mapConfig
|
||||||
|
export const selectSettings = (state: AppState) => state.settings
|
||||||
|
export const selectIsMapExpanded = (state: AppState) => state.mapConfig.expanded
|
||||||
|
export const selectIsMapStreaming = (state: AppState) => state.isMapStreaming
|
||||||
131
frontend/tailwind.config.js
Normal file
131
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
50: '#f0fdf4',
|
||||||
|
100: '#dcfce7',
|
||||||
|
200: '#bbf7d0',
|
||||||
|
300: '#86efac',
|
||||||
|
400: '#4ade80',
|
||||||
|
500: '#22c55e',
|
||||||
|
600: '#16a34a',
|
||||||
|
700: '#15803d',
|
||||||
|
800: '#166534',
|
||||||
|
900: '#14532d',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#fefce8',
|
||||||
|
100: '#fef9c3',
|
||||||
|
200: '#fef08a',
|
||||||
|
300: '#fde047',
|
||||||
|
400: '#facc15',
|
||||||
|
500: '#eab308',
|
||||||
|
600: '#ca8a04',
|
||||||
|
700: '#a16207',
|
||||||
|
800: '#854d0e',
|
||||||
|
900: '#713f12',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
50: '#fef2f2',
|
||||||
|
100: '#fee2e2',
|
||||||
|
200: '#fecaca',
|
||||||
|
300: '#fca5a5',
|
||||||
|
400: '#f87171',
|
||||||
|
500: '#ef4444',
|
||||||
|
600: '#dc2626',
|
||||||
|
700: '#b91c1c',
|
||||||
|
800: '#991b1b',
|
||||||
|
900: '#7f1d1d',
|
||||||
|
},
|
||||||
|
golf: {
|
||||||
|
green: '#2D5016',
|
||||||
|
fairway: '#355E3B',
|
||||||
|
sand: '#C2B280',
|
||||||
|
water: '#4682B4',
|
||||||
|
rough: '#3A5F0B',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', 'monospace'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||||
|
'fade-out': 'fadeOut 0.3s ease-in-out',
|
||||||
|
'slide-in': 'slideIn 0.3s ease-out',
|
||||||
|
'slide-out': 'slideOut 0.3s ease-out',
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'bounce-slow': 'bounce 2s infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
fadeOut: {
|
||||||
|
'0%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0' },
|
||||||
|
},
|
||||||
|
slideIn: {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
slideOut: {
|
||||||
|
'0%': { transform: 'translateX(0)' },
|
||||||
|
'100%': { transform: 'translateX(100%)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'golf': '0 4px 6px -1px rgba(45, 80, 22, 0.1), 0 2px 4px -1px rgba(45, 80, 22, 0.06)',
|
||||||
|
'golf-lg': '0 10px 15px -3px rgba(45, 80, 22, 0.1), 0 4px 6px -2px rgba(45, 80, 22, 0.05)',
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||||
|
'golf-pattern': "url('data:image/svg+xml,%3Csvg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"%232D5016\" fill-opacity=\"0.03\"%3E%3Cpath d=\"M0 40L40 0H20L0 20M40 40V20L20 40\"%3E%3C/path%3E%3C/g%3E%3C/svg%3E')",
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
'18': '4.5rem',
|
||||||
|
'88': '22rem',
|
||||||
|
'120': '30rem',
|
||||||
|
'128': '32rem',
|
||||||
|
'144': '36rem',
|
||||||
|
},
|
||||||
|
screens: {
|
||||||
|
'xs': '480px',
|
||||||
|
'3xl': '1920px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
38
frontend/tsconfig.json
Normal file
38
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@components/*": ["src/components/*"],
|
||||||
|
"@pages/*": ["src/pages/*"],
|
||||||
|
"@hooks/*": ["src/hooks/*"],
|
||||||
|
"@utils/*": ["src/utils/*"],
|
||||||
|
"@api/*": ["src/api/*"],
|
||||||
|
"@stores/*": ["src/stores/*"],
|
||||||
|
"@types/*": ["src/types/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
47
frontend/vite.config.ts
Normal file
47
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@components': path.resolve(__dirname, './src/components'),
|
||||||
|
'@pages': path.resolve(__dirname, './src/pages'),
|
||||||
|
'@hooks': path.resolve(__dirname, './src/hooks'),
|
||||||
|
'@utils': path.resolve(__dirname, './src/utils'),
|
||||||
|
'@api': path.resolve(__dirname, './src/api'),
|
||||||
|
'@stores': path.resolve(__dirname, './src/stores'),
|
||||||
|
'@types': path.resolve(__dirname, './src/types'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5005',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:5005',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../backend/ui',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'react-vendor': ['react', 'react-dom'],
|
||||||
|
'ui-vendor': ['react-icons', 'clsx', 'react-hot-toast'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
193
scripts/dev.ps1
Normal file
193
scripts/dev.ps1
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
# GSPro Remote Development Script
|
||||||
|
# Starts both backend and frontend servers for development
|
||||||
|
|
||||||
|
Write-Host "GSPro Remote Development Environment" -ForegroundColor Green
|
||||||
|
Write-Host "=====================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Check if Python is installed
|
||||||
|
$pythonVersion = python --version 2>$null
|
||||||
|
if (-not $pythonVersion) {
|
||||||
|
Write-Host "ERROR: Python is not installed or not in PATH" -ForegroundColor Red
|
||||||
|
Write-Host "Please install Python 3.11+ from https://www.python.org/" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ Python found: $pythonVersion" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Check if Node.js is installed
|
||||||
|
$nodeVersion = node --version 2>$null
|
||||||
|
if (-not $nodeVersion) {
|
||||||
|
Write-Host "ERROR: Node.js is not installed or not in PATH" -ForegroundColor Red
|
||||||
|
Write-Host "Please install Node.js 20+ from https://nodejs.org/" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ Node.js found: $nodeVersion" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Check if npm is installed
|
||||||
|
$npmVersion = npm --version 2>$null
|
||||||
|
if (-not $npmVersion) {
|
||||||
|
Write-Host "ERROR: npm is not installed or not in PATH" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "✓ npm found: $npmVersion" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Get the script directory (project root)
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$projectRoot = Split-Path -Parent $scriptDir
|
||||||
|
$backendDir = Join-Path $projectRoot "backend"
|
||||||
|
$frontendDir = Join-Path $projectRoot "frontend"
|
||||||
|
|
||||||
|
# Function to check if a port is in use
|
||||||
|
function Test-Port {
|
||||||
|
param($port)
|
||||||
|
$tcpClient = New-Object System.Net.Sockets.TcpClient
|
||||||
|
try {
|
||||||
|
$tcpClient.Connect("localhost", $port)
|
||||||
|
$tcpClient.Close()
|
||||||
|
return $true
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if backend port is already in use
|
||||||
|
if (Test-Port 5005) {
|
||||||
|
Write-Host "WARNING: Port 5005 is already in use. Backend server might already be running." -ForegroundColor Yellow
|
||||||
|
Write-Host "Do you want to continue anyway? (Y/N): " -NoNewline
|
||||||
|
$response = Read-Host
|
||||||
|
if ($response -ne "Y" -and $response -ne "y") {
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if frontend port is already in use
|
||||||
|
if (Test-Port 5173) {
|
||||||
|
Write-Host "WARNING: Port 5173 is already in use. Frontend server might already be running." -ForegroundColor Yellow
|
||||||
|
Write-Host "Do you want to continue anyway? (Y/N): " -NoNewline
|
||||||
|
$response = Read-Host
|
||||||
|
if ($response -ne "Y" -and $response -ne "y") {
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup backend
|
||||||
|
Write-Host "Setting up Backend..." -ForegroundColor Cyan
|
||||||
|
Set-Location $backendDir
|
||||||
|
|
||||||
|
# Check if virtual environment exists
|
||||||
|
$venvPath = Join-Path $backendDir ".venv"
|
||||||
|
if (-not (Test-Path $venvPath)) {
|
||||||
|
Write-Host "Creating Python virtual environment..." -ForegroundColor Yellow
|
||||||
|
python -m venv .venv
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERROR: Failed to create virtual environment" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Activate virtual environment and install dependencies
|
||||||
|
Write-Host "Installing backend dependencies..." -ForegroundColor Yellow
|
||||||
|
$activateScript = Join-Path $venvPath "Scripts\Activate.ps1"
|
||||||
|
if (Test-Path $activateScript) {
|
||||||
|
& $activateScript
|
||||||
|
} else {
|
||||||
|
Write-Host "ERROR: Virtual environment activation script not found" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if requirements are installed
|
||||||
|
$pipList = pip list 2>$null
|
||||||
|
if (-not ($pipList -like "*fastapi*")) {
|
||||||
|
Write-Host "Installing Python packages..." -ForegroundColor Yellow
|
||||||
|
pip install -e . 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERROR: Failed to install backend dependencies" -ForegroundColor Red
|
||||||
|
Write-Host "Try running: pip install -e . manually in the backend directory" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Backend dependencies installed" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Setup frontend
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Setting up Frontend..." -ForegroundColor Cyan
|
||||||
|
Set-Location $frontendDir
|
||||||
|
|
||||||
|
# Check if node_modules exists
|
||||||
|
if (-not (Test-Path "node_modules")) {
|
||||||
|
Write-Host "Installing frontend dependencies..." -ForegroundColor Yellow
|
||||||
|
npm install 2>&1 | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "ERROR: Failed to install frontend dependencies" -ForegroundColor Red
|
||||||
|
Write-Host "Try running: npm install manually in the frontend directory" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Frontend dependencies installed" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Start servers
|
||||||
|
Write-Host "Starting Development Servers..." -ForegroundColor Cyan
|
||||||
|
Write-Host "================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Start backend server in a new PowerShell window
|
||||||
|
Write-Host "Starting Backend Server on http://localhost:5005" -ForegroundColor Green
|
||||||
|
$backendScript = @"
|
||||||
|
Write-Host 'GSPro Remote Backend Server' -ForegroundColor Cyan
|
||||||
|
Write-Host '===========================' -ForegroundColor Cyan
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'Server: http://localhost:5005' -ForegroundColor Yellow
|
||||||
|
Write-Host 'API Docs: http://localhost:5005/api/docs' -ForegroundColor Yellow
|
||||||
|
Write-Host 'mDNS: http://gsproapp.local:5005' -ForegroundColor Yellow
|
||||||
|
Write-Host ''
|
||||||
|
Set-Location '$backendDir'
|
||||||
|
& '$activateScript'
|
||||||
|
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 5005 --log-level info
|
||||||
|
"@
|
||||||
|
|
||||||
|
$backendScriptPath = Join-Path $env:TEMP "gspro-backend.ps1"
|
||||||
|
$backendScript | Out-File -FilePath $backendScriptPath -Encoding UTF8
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $backendScriptPath
|
||||||
|
|
||||||
|
# Give backend a moment to start
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Start frontend server in a new PowerShell window
|
||||||
|
Write-Host "Starting Frontend Server on http://localhost:5173" -ForegroundColor Green
|
||||||
|
$frontendScript = @"
|
||||||
|
Write-Host 'GSPro Remote Frontend Server' -ForegroundColor Cyan
|
||||||
|
Write-Host '============================' -ForegroundColor Cyan
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'UI: http://localhost:5173' -ForegroundColor Yellow
|
||||||
|
Write-Host ''
|
||||||
|
Set-Location '$frontendDir'
|
||||||
|
npm run dev
|
||||||
|
"@
|
||||||
|
|
||||||
|
$frontendScriptPath = Join-Path $env:TEMP "gspro-frontend.ps1"
|
||||||
|
$frontendScript | Out-File -FilePath $frontendScriptPath -Encoding UTF8
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $frontendScriptPath
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Development servers starting..." -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Access Points:" -ForegroundColor Cyan
|
||||||
|
Write-Host " Frontend UI: http://localhost:5173" -ForegroundColor White
|
||||||
|
Write-Host " Backend API: http://localhost:5005" -ForegroundColor White
|
||||||
|
Write-Host " API Docs: http://localhost:5005/api/docs" -ForegroundColor White
|
||||||
|
Write-Host " mDNS Access: http://gsproapp.local:5005" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Press any key to stop watching for changes..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Wait for user input
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
|
||||||
|
# The servers are running in separate windows, so they'll keep running
|
||||||
|
# This script can exit, and the user can close the server windows manually
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Note: The servers are still running in separate windows." -ForegroundColor Yellow
|
||||||
|
Write-Host "Close those windows to stop the servers." -ForegroundColor Yellow
|
||||||
94
start.bat
Normal file
94
start.bat
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@echo off
|
||||||
|
echo ===============================================
|
||||||
|
echo GSPro Remote - Starting Development Servers
|
||||||
|
echo ===============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if Python is installed
|
||||||
|
python --version >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo ERROR: Python is not installed or not in PATH
|
||||||
|
echo Please install Python 3.11+ from https://www.python.org/
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Check if Node.js is installed
|
||||||
|
node --version >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo ERROR: Node.js is not installed or not in PATH
|
||||||
|
echo Please install Node.js 20+ from https://nodejs.org/
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Get the directory where this script is located
|
||||||
|
set SCRIPT_DIR=%~dp0
|
||||||
|
set PROJECT_ROOT=%SCRIPT_DIR%
|
||||||
|
set BACKEND_DIR=%PROJECT_ROOT%backend
|
||||||
|
set FRONTEND_DIR=%PROJECT_ROOT%frontend
|
||||||
|
|
||||||
|
echo Project Root: %PROJECT_ROOT%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if backend virtual environment exists
|
||||||
|
if not exist "%BACKEND_DIR%\.venv" (
|
||||||
|
echo Creating Python virtual environment...
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
python -m venv .venv
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Install backend dependencies
|
||||||
|
echo Checking backend dependencies...
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
call .venv\Scripts\activate.bat
|
||||||
|
pip show fastapi >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo Installing backend dependencies...
|
||||||
|
pip install -e .
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Check if frontend node_modules exists
|
||||||
|
if not exist "%FRONTEND_DIR%\node_modules" (
|
||||||
|
echo Installing frontend dependencies...
|
||||||
|
cd /d "%FRONTEND_DIR%"
|
||||||
|
npm install
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ===============================================
|
||||||
|
echo Starting Backend Server on port 5005...
|
||||||
|
echo ===============================================
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
start "GSPro Remote Backend" cmd /k ".venv\Scripts\activate.bat && python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 5005"
|
||||||
|
|
||||||
|
REM Wait a moment for backend to start
|
||||||
|
timeout /t 3 /nobreak >nul
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ===============================================
|
||||||
|
echo Starting Frontend Server on port 5173...
|
||||||
|
echo ===============================================
|
||||||
|
cd /d "%FRONTEND_DIR%"
|
||||||
|
start "GSPro Remote Frontend" cmd /k "npm run dev -- --host"
|
||||||
|
|
||||||
|
REM Wait for servers to initialize
|
||||||
|
timeout /t 3 /nobreak >nul
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ===============================================
|
||||||
|
echo Servers are starting up...
|
||||||
|
echo ===============================================
|
||||||
|
echo.
|
||||||
|
echo Access the application at:
|
||||||
|
echo Local: http://localhost:5173
|
||||||
|
echo Network: http://YOUR-IP:5173
|
||||||
|
echo Backend API: http://localhost:5005
|
||||||
|
echo API Docs: http://localhost:5005/api/docs
|
||||||
|
echo.
|
||||||
|
echo To find your IP address, run: ipconfig
|
||||||
|
echo.
|
||||||
|
echo Both servers are running in separate windows.
|
||||||
|
echo Close those windows to stop the servers.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
250
start.py
Normal file
250
start.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
GSPro Remote - Development Server Startup Script
|
||||||
|
Simple Python script to start both backend and frontend servers
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Colors for terminal output
|
||||||
|
class Colors:
|
||||||
|
HEADER = "\033[95m"
|
||||||
|
OKBLUE = "\033[94m"
|
||||||
|
OKCYAN = "\033[96m"
|
||||||
|
OKGREEN = "\033[92m"
|
||||||
|
WARNING = "\033[93m"
|
||||||
|
FAIL = "\033[91m"
|
||||||
|
ENDC = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
|
||||||
|
|
||||||
|
def print_header(text):
|
||||||
|
print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 50}{Colors.ENDC}")
|
||||||
|
print(f"{Colors.HEADER}{Colors.BOLD}{text}{Colors.ENDC}")
|
||||||
|
print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 50}{Colors.ENDC}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def print_success(text):
|
||||||
|
print(f"{Colors.OKGREEN}✓ {text}{Colors.ENDC}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_error(text):
|
||||||
|
print(f"{Colors.FAIL}✗ {text}{Colors.ENDC}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_info(text):
|
||||||
|
print(f"{Colors.OKCYAN}→ {text}{Colors.ENDC}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_python():
|
||||||
|
"""Check if Python is installed and version is 3.11+"""
|
||||||
|
version = sys.version_info
|
||||||
|
if version.major < 3 or (version.major == 3 and version.minor < 11):
|
||||||
|
print_error(f"Python 3.11+ required, found {version.major}.{version.minor}")
|
||||||
|
return False
|
||||||
|
print_success(f"Python {version.major}.{version.minor} found")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_node():
|
||||||
|
"""Check if Node.js is installed"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["node", "--version"], capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print_success(f"Node.js {result.stdout.strip()} found")
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
print_error("Node.js not found. Please install from https://nodejs.org/")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_npm():
|
||||||
|
"""Check if npm is installed"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["npm", "--version"], capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print_success(f"npm {result.stdout.strip()} found")
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
print_error("npm not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def setup_backend(backend_dir):
|
||||||
|
"""Setup backend virtual environment and dependencies"""
|
||||||
|
venv_path = backend_dir / ".venv"
|
||||||
|
|
||||||
|
# Create virtual environment if it doesn't exist
|
||||||
|
if not venv_path.exists():
|
||||||
|
print_info("Creating Python virtual environment...")
|
||||||
|
subprocess.run([sys.executable, "-m", "venv", str(venv_path)], cwd=backend_dir)
|
||||||
|
|
||||||
|
# Determine the activation script based on OS
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
activate_script = venv_path / "Scripts" / "activate.bat"
|
||||||
|
python_exe = venv_path / "Scripts" / "python.exe"
|
||||||
|
pip_exe = venv_path / "Scripts" / "pip.exe"
|
||||||
|
else:
|
||||||
|
activate_script = venv_path / "bin" / "activate"
|
||||||
|
python_exe = venv_path / "bin" / "python"
|
||||||
|
pip_exe = venv_path / "bin" / "pip"
|
||||||
|
|
||||||
|
# Check if dependencies are installed
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(pip_exe), "show", "fastapi"], capture_output=True, cwd=backend_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print_info("Installing backend dependencies...")
|
||||||
|
subprocess.run([str(pip_exe), "install", "-e", "."], cwd=backend_dir)
|
||||||
|
else:
|
||||||
|
print_success("Backend dependencies already installed")
|
||||||
|
|
||||||
|
return python_exe
|
||||||
|
|
||||||
|
|
||||||
|
def setup_frontend(frontend_dir):
|
||||||
|
"""Setup frontend dependencies"""
|
||||||
|
node_modules = frontend_dir / "node_modules"
|
||||||
|
|
||||||
|
if not node_modules.exists():
|
||||||
|
print_info("Installing frontend dependencies...")
|
||||||
|
subprocess.run(["npm", "install"], cwd=frontend_dir, shell=True)
|
||||||
|
else:
|
||||||
|
print_success("Frontend dependencies already installed")
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_ip():
|
||||||
|
"""Get local IP address"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to a public DNS server to get local IP
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
except:
|
||||||
|
return "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print_header("GSPro Remote - Development Server Startup")
|
||||||
|
|
||||||
|
# Get project directories
|
||||||
|
script_dir = Path(__file__).parent.resolve()
|
||||||
|
backend_dir = script_dir / "backend"
|
||||||
|
frontend_dir = script_dir / "frontend"
|
||||||
|
|
||||||
|
print(f"Project root: {script_dir}")
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
print_header("Checking Prerequisites")
|
||||||
|
if not check_python():
|
||||||
|
sys.exit(1)
|
||||||
|
if not check_node():
|
||||||
|
sys.exit(1)
|
||||||
|
if not check_npm():
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Setup backend
|
||||||
|
print_header("Setting up Backend")
|
||||||
|
os.chdir(backend_dir)
|
||||||
|
python_exe = setup_backend(backend_dir)
|
||||||
|
|
||||||
|
# Setup frontend
|
||||||
|
print_header("Setting up Frontend")
|
||||||
|
os.chdir(frontend_dir)
|
||||||
|
setup_frontend(frontend_dir)
|
||||||
|
|
||||||
|
# Start backend server
|
||||||
|
print_header("Starting Backend Server")
|
||||||
|
backend_cmd = [
|
||||||
|
str(python_exe),
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
"app.main:app",
|
||||||
|
"--reload",
|
||||||
|
"--host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"5005",
|
||||||
|
"--log-level",
|
||||||
|
"info",
|
||||||
|
]
|
||||||
|
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
backend_process = subprocess.Popen(
|
||||||
|
backend_cmd, cwd=backend_dir, creationflags=subprocess.CREATE_NEW_CONSOLE
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
backend_process = subprocess.Popen(backend_cmd, cwd=backend_dir)
|
||||||
|
|
||||||
|
print_success("Backend server starting on http://localhost:5005")
|
||||||
|
|
||||||
|
# Wait for backend to start
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# Start frontend server
|
||||||
|
print_header("Starting Frontend Server")
|
||||||
|
frontend_cmd = "npm run dev -- --host"
|
||||||
|
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
frontend_process = subprocess.Popen(
|
||||||
|
frontend_cmd,
|
||||||
|
shell=True,
|
||||||
|
cwd=frontend_dir,
|
||||||
|
creationflags=subprocess.CREATE_NEW_CONSOLE,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
frontend_process = subprocess.Popen(frontend_cmd, shell=True, cwd=frontend_dir)
|
||||||
|
|
||||||
|
print_success("Frontend server starting on http://localhost:5173")
|
||||||
|
|
||||||
|
# Get local IP
|
||||||
|
local_ip = get_local_ip()
|
||||||
|
|
||||||
|
# Print access information
|
||||||
|
print_header("Servers Started Successfully!")
|
||||||
|
print(f"{Colors.OKGREEN}Access the application at:{Colors.ENDC}")
|
||||||
|
print(f" {Colors.BOLD}Local:{Colors.ENDC} http://localhost:5173")
|
||||||
|
print(f" {Colors.BOLD}Network:{Colors.ENDC} http://{local_ip}:5173")
|
||||||
|
print(f" {Colors.BOLD}Backend API:{Colors.ENDC} http://localhost:5005")
|
||||||
|
print(f" {Colors.BOLD}API Docs:{Colors.ENDC} http://localhost:5005/api/docs")
|
||||||
|
print(f" {Colors.BOLD}mDNS:{Colors.ENDC} http://gsproapp.local:5005")
|
||||||
|
|
||||||
|
print(f"\n{Colors.WARNING}To access from your phone/tablet:{Colors.ENDC}")
|
||||||
|
print(f"1. Ensure your device is on the same WiFi network")
|
||||||
|
print(f"2. Open a browser and go to: http://{local_ip}:5173")
|
||||||
|
|
||||||
|
print(f"\n{Colors.BOLD}Press Ctrl+C to stop all servers{Colors.ENDC}\n")
|
||||||
|
|
||||||
|
# Keep script running and handle shutdown
|
||||||
|
try:
|
||||||
|
# Open browser after a short delay
|
||||||
|
time.sleep(2)
|
||||||
|
webbrowser.open("http://localhost:5173")
|
||||||
|
|
||||||
|
# Wait for processes
|
||||||
|
backend_process.wait()
|
||||||
|
frontend_process.wait()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n{Colors.WARNING}Shutting down servers...{Colors.ENDC}")
|
||||||
|
backend_process.terminate()
|
||||||
|
frontend_process.terminate()
|
||||||
|
print_success("Servers stopped")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue