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

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