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

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