Initial commit: GSPro Remote MVP - Phase 1 complete

This commit is contained in:
Ryan Hill 2025-11-13 15:38:58 -06:00
commit 74ca4b38eb
50 changed files with 12818 additions and 0 deletions

47
.editorconfig Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

229
PRD.md Normal file
View file

@ -0,0 +1,229 @@
# Product Requirements Document (PRD)
**Product:** GSPro Remote
**Version:** 0.1.0 (Restart Foundation)
**Author:** \[You]
**Date:** \[Todays 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 apps 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.
* Doesnt 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 doesnt 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
View 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
View 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 youll 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). Its 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, \~7585 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
View 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
View 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
View 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__"]

View 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
View 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
View 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
View 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
View 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,
},
}

View 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
View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

36
frontend/package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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
View 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
View 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
View 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 '◯'
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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'))

View 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

View 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
View 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
View 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" }]
}

View 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
View 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
View 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
View 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
View 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()