Spaces:
Running
Running
Refactored APIs
Browse files
reachy_f1_commentator/main.py
CHANGED
|
@@ -46,7 +46,8 @@ _app_instance = None
|
|
| 46 |
class ReachyF1Commentator(ReachyMiniApp):
|
| 47 |
"""Main Reachy Mini app for F1 commentary generation."""
|
| 48 |
|
| 49 |
-
custom_app_url: str = "/
|
|
|
|
| 50 |
|
| 51 |
def __init__(self):
|
| 52 |
"""Initialize the F1 commentator app."""
|
|
@@ -78,9 +79,11 @@ class ReachyF1Commentator(ReachyMiniApp):
|
|
| 78 |
logger.info("Starting F1 Commentator app")
|
| 79 |
|
| 80 |
self.reachy_mini_instance = reachy_mini
|
| 81 |
-
# state_tracker already initialized in __init__
|
| 82 |
|
| 83 |
-
#
|
|
|
|
|
|
|
|
|
|
| 84 |
logger.info(f"Web UI available at {self.custom_app_url}")
|
| 85 |
|
| 86 |
# Wait for stop_event or user interaction
|
|
@@ -92,6 +95,275 @@ class ReachyF1Commentator(ReachyMiniApp):
|
|
| 92 |
finally:
|
| 93 |
self._cleanup()
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def start_commentary(self, config: WebUIConfiguration) -> dict:
|
| 96 |
"""
|
| 97 |
Start commentary playback with given configuration.
|
|
@@ -648,8 +920,9 @@ class ReachyF1Commentator(ReachyMiniApp):
|
|
| 648 |
|
| 649 |
|
| 650 |
|
| 651 |
-
#
|
| 652 |
-
|
|
|
|
| 653 |
|
| 654 |
# WebSocket connections for live dashboard
|
| 655 |
from fastapi import WebSocket, WebSocketDisconnect
|
|
@@ -692,13 +965,8 @@ class ConnectionManager:
|
|
| 692 |
for conn in disconnected:
|
| 693 |
self.disconnect(conn)
|
| 694 |
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
# Mount static files
|
| 698 |
-
import os
|
| 699 |
-
static_path = os.path.join(os.path.dirname(__file__), "static")
|
| 700 |
-
if os.path.exists(static_path):
|
| 701 |
-
app.mount("/static", StaticFiles(directory=static_path, html=True), name="static")
|
| 702 |
|
| 703 |
|
| 704 |
# Pydantic models for API
|
|
@@ -716,7 +984,12 @@ class ConfigSaveRequest(BaseModel):
|
|
| 716 |
elevenlabs_voice_id: str = 'HSSEHuB5EziJgTfCVmC6'
|
| 717 |
|
| 718 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
# Configuration file path
|
|
|
|
| 720 |
CONFIG_DIR = os.path.expanduser("~/.reachy_f1_commentator")
|
| 721 |
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
| 722 |
|
|
@@ -747,259 +1020,6 @@ def save_config(config: dict) -> bool:
|
|
| 747 |
return False
|
| 748 |
|
| 749 |
|
| 750 |
-
@app.get("/api/config")
|
| 751 |
-
async def get_config():
|
| 752 |
-
"""Get saved configuration."""
|
| 753 |
-
try:
|
| 754 |
-
config = load_saved_config()
|
| 755 |
-
return {
|
| 756 |
-
"elevenlabs_api_key": config.get("elevenlabs_api_key", ""),
|
| 757 |
-
"elevenlabs_voice_id": config.get("elevenlabs_voice_id", "HSSEHuB5EziJgTfCVmC6")
|
| 758 |
-
}
|
| 759 |
-
except Exception as e:
|
| 760 |
-
logger.error(f"Failed to get config: {e}")
|
| 761 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
@app.post("/api/config")
|
| 765 |
-
async def save_config_endpoint(request: ConfigSaveRequest):
|
| 766 |
-
"""Save configuration."""
|
| 767 |
-
try:
|
| 768 |
-
config = {
|
| 769 |
-
"elevenlabs_api_key": request.elevenlabs_api_key,
|
| 770 |
-
"elevenlabs_voice_id": request.elevenlabs_voice_id
|
| 771 |
-
}
|
| 772 |
-
|
| 773 |
-
if save_config(config):
|
| 774 |
-
return {"status": "saved", "message": "Configuration saved successfully"}
|
| 775 |
-
else:
|
| 776 |
-
raise HTTPException(status_code=500, detail="Failed to save configuration")
|
| 777 |
-
except Exception as e:
|
| 778 |
-
logger.error(f"Failed to save config: {e}")
|
| 779 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
@app.get("/api/races/years")
|
| 783 |
-
async def get_years():
|
| 784 |
-
"""Get list of available years with race data."""
|
| 785 |
-
try:
|
| 786 |
-
if _app_instance is None:
|
| 787 |
-
raise HTTPException(status_code=503, detail="App not initialized")
|
| 788 |
-
|
| 789 |
-
years = _app_instance.openf1_client.get_years()
|
| 790 |
-
return {"years": years}
|
| 791 |
-
except Exception as e:
|
| 792 |
-
logger.error(f"Failed to get years: {e}")
|
| 793 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
@app.get("/api/races/{year}")
|
| 797 |
-
async def get_races(year: int):
|
| 798 |
-
"""Get all races for a specific year."""
|
| 799 |
-
try:
|
| 800 |
-
if _app_instance is None:
|
| 801 |
-
raise HTTPException(status_code=503, detail="App not initialized")
|
| 802 |
-
|
| 803 |
-
logger.info(f"Fetching races for year {year}")
|
| 804 |
-
races = _app_instance.openf1_client.get_races_by_year(year)
|
| 805 |
-
|
| 806 |
-
if not races:
|
| 807 |
-
logger.warning(f"No races found for year {year}")
|
| 808 |
-
return {"races": []}
|
| 809 |
-
|
| 810 |
-
# Convert to dict format
|
| 811 |
-
races_data = [
|
| 812 |
-
{
|
| 813 |
-
"session_key": race.session_key,
|
| 814 |
-
"date": race.date,
|
| 815 |
-
"country": race.country,
|
| 816 |
-
"circuit": race.circuit,
|
| 817 |
-
"name": race.name
|
| 818 |
-
}
|
| 819 |
-
for race in races
|
| 820 |
-
]
|
| 821 |
-
|
| 822 |
-
logger.info(f"Returning {len(races_data)} races for year {year}")
|
| 823 |
-
return {"races": races_data}
|
| 824 |
-
except Exception as e:
|
| 825 |
-
logger.error(f"Failed to get races for year {year}: {e}", exc_info=True)
|
| 826 |
-
raise HTTPException(status_code=500, detail=f"Failed to load races: {str(e)}")
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
@app.post("/api/commentary/start")
|
| 830 |
-
async def start_commentary(request: CommentaryStartRequest):
|
| 831 |
-
"""Start commentary playback."""
|
| 832 |
-
try:
|
| 833 |
-
if _app_instance is None:
|
| 834 |
-
raise HTTPException(status_code=503, detail="App not initialized")
|
| 835 |
-
|
| 836 |
-
# Convert request to WebUIConfiguration
|
| 837 |
-
config = WebUIConfiguration(
|
| 838 |
-
mode=request.mode,
|
| 839 |
-
session_key=request.session_key,
|
| 840 |
-
commentary_mode=request.commentary_mode,
|
| 841 |
-
playback_speed=request.playback_speed,
|
| 842 |
-
elevenlabs_api_key=request.elevenlabs_api_key,
|
| 843 |
-
elevenlabs_voice_id=request.elevenlabs_voice_id
|
| 844 |
-
)
|
| 845 |
-
|
| 846 |
-
result = _app_instance.start_commentary(config)
|
| 847 |
-
return result
|
| 848 |
-
except Exception as e:
|
| 849 |
-
logger.error(f"Failed to start commentary: {e}")
|
| 850 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
@app.post("/api/commentary/stop")
|
| 854 |
-
async def stop_commentary():
|
| 855 |
-
"""Stop active commentary playback."""
|
| 856 |
-
try:
|
| 857 |
-
if _app_instance is None:
|
| 858 |
-
raise HTTPException(status_code=503, detail="App not initialized")
|
| 859 |
-
|
| 860 |
-
result = _app_instance.stop_commentary()
|
| 861 |
-
return result
|
| 862 |
-
except Exception as e:
|
| 863 |
-
logger.error(f"Failed to stop commentary: {e}")
|
| 864 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
@app.get("/api/commentary/status")
|
| 868 |
-
async def get_status():
|
| 869 |
-
"""Get current playback status."""
|
| 870 |
-
try:
|
| 871 |
-
if _app_instance is None:
|
| 872 |
-
raise HTTPException(status_code=503, detail="App not initialized")
|
| 873 |
-
|
| 874 |
-
status = _app_instance.get_status()
|
| 875 |
-
return status
|
| 876 |
-
except Exception as e:
|
| 877 |
-
logger.error(f"Failed to get status: {e}")
|
| 878 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
class QuestionRequest(BaseModel):
|
| 882 |
-
question: str
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
@app.post("/api/qa/ask")
|
| 886 |
-
async def ask_question(request: QuestionRequest):
|
| 887 |
-
"""Handle Q&A question from user."""
|
| 888 |
-
try:
|
| 889 |
-
if _app_instance is None:
|
| 890 |
-
raise HTTPException(status_code=503, detail="App not initialized")
|
| 891 |
-
|
| 892 |
-
if not request.question or not request.question.strip():
|
| 893 |
-
raise HTTPException(status_code=400, detail="Question cannot be empty")
|
| 894 |
-
|
| 895 |
-
# Process question using QA manager
|
| 896 |
-
answer = _app_instance.process_question(request.question)
|
| 897 |
-
|
| 898 |
-
return {"question": request.question, "answer": answer}
|
| 899 |
-
except Exception as e:
|
| 900 |
-
logger.error(f"Failed to process question: {e}", exc_info=True)
|
| 901 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
@app.get("/")
|
| 905 |
-
async def root():
|
| 906 |
-
"""Redirect to static UI."""
|
| 907 |
-
from fastapi.responses import RedirectResponse
|
| 908 |
-
return RedirectResponse(url="/static/index.html")
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
# Health check endpoint
|
| 912 |
-
@app.get("/health")
|
| 913 |
-
async def health():
|
| 914 |
-
"""Health check endpoint."""
|
| 915 |
-
return {"status": "healthy", "app": "reachy-f1-commentator"}
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
@app.websocket("/ws/dashboard")
|
| 919 |
-
async def dashboard_websocket(websocket: WebSocket):
|
| 920 |
-
"""WebSocket endpoint for live dashboard updates."""
|
| 921 |
-
await dashboard_manager.connect(websocket)
|
| 922 |
-
try:
|
| 923 |
-
while True:
|
| 924 |
-
# Process any queued broadcasts
|
| 925 |
-
while dashboard_manager.broadcast_queue:
|
| 926 |
-
message = dashboard_manager.broadcast_queue.pop(0)
|
| 927 |
-
await dashboard_manager.broadcast(message)
|
| 928 |
-
|
| 929 |
-
# Wait a bit before checking queue again
|
| 930 |
-
await asyncio.sleep(0.1)
|
| 931 |
-
|
| 932 |
-
# Check if client sent any messages (for keep-alive)
|
| 933 |
-
try:
|
| 934 |
-
data = await asyncio.wait_for(websocket.receive_text(), timeout=0.1)
|
| 935 |
-
# Echo back for ping/pong
|
| 936 |
-
await websocket.send_text(data)
|
| 937 |
-
except asyncio.TimeoutError:
|
| 938 |
-
# No message received, continue
|
| 939 |
-
pass
|
| 940 |
-
|
| 941 |
-
except WebSocketDisconnect:
|
| 942 |
-
dashboard_manager.disconnect(websocket)
|
| 943 |
-
except Exception as e:
|
| 944 |
-
logger.error(f"WebSocket error: {e}")
|
| 945 |
-
dashboard_manager.disconnect(websocket)
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
@app.get("/api/dashboard/state")
|
| 949 |
-
async def get_dashboard_state():
|
| 950 |
-
"""Get current race state for dashboard."""
|
| 951 |
-
try:
|
| 952 |
-
if _app_instance is None or _app_instance.state_tracker is None:
|
| 953 |
-
return {
|
| 954 |
-
"positions": [],
|
| 955 |
-
"race_info": {
|
| 956 |
-
"current_lap": 0,
|
| 957 |
-
"total_laps": 0,
|
| 958 |
-
"leader": None,
|
| 959 |
-
"fastest_lap_holder": None,
|
| 960 |
-
"race_phase": "START"
|
| 961 |
-
},
|
| 962 |
-
"recent_events": []
|
| 963 |
-
}
|
| 964 |
-
|
| 965 |
-
# Get positions
|
| 966 |
-
positions = _app_instance.state_tracker.get_positions()
|
| 967 |
-
positions_data = [
|
| 968 |
-
{
|
| 969 |
-
"position": p.position,
|
| 970 |
-
"driver": p.name,
|
| 971 |
-
"gap_to_leader": f"+{p.gap_to_leader:.3f}s" if p.gap_to_leader > 0 else "Leader",
|
| 972 |
-
"tire_compound": p.current_tire or "Unknown",
|
| 973 |
-
"pit_stops": p.pit_count,
|
| 974 |
-
"team": "Unknown" # Team info not available in DriverState
|
| 975 |
-
}
|
| 976 |
-
for p in positions[:20] # Top 20
|
| 977 |
-
]
|
| 978 |
-
|
| 979 |
-
# Get race info
|
| 980 |
-
leader = _app_instance.state_tracker.get_leader()
|
| 981 |
-
state = _app_instance.state_tracker._state # Access private _state attribute
|
| 982 |
-
|
| 983 |
-
race_info = {
|
| 984 |
-
"current_lap": state.current_lap,
|
| 985 |
-
"total_laps": state.total_laps,
|
| 986 |
-
"leader": leader.name if leader else None,
|
| 987 |
-
"fastest_lap_holder": state.fastest_lap_driver,
|
| 988 |
-
"fastest_lap_time": f"{state.fastest_lap_time:.3f}s" if state.fastest_lap_time else None,
|
| 989 |
-
"race_phase": state.race_phase.value if state.race_phase else "START",
|
| 990 |
-
"safety_car": state.safety_car_active
|
| 991 |
-
}
|
| 992 |
-
|
| 993 |
-
return {
|
| 994 |
-
"positions": positions_data,
|
| 995 |
-
"race_info": race_info,
|
| 996 |
-
"recent_events": [] # TODO: Add event history
|
| 997 |
-
}
|
| 998 |
-
except Exception as e:
|
| 999 |
-
logger.error(f"Failed to get dashboard state: {e}", exc_info=True)
|
| 1000 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
# ============================================================================
|
| 1004 |
# Standalone Mode Entry Point
|
| 1005 |
# ============================================================================
|
|
@@ -1014,6 +1034,7 @@ if __name__ == "__main__":
|
|
| 1014 |
The app will auto-detect and connect to Reachy if available.
|
| 1015 |
"""
|
| 1016 |
import uvicorn
|
|
|
|
| 1017 |
|
| 1018 |
logger.info("=" * 60)
|
| 1019 |
logger.info("Starting Reachy F1 Commentator in standalone mode")
|
|
@@ -1038,15 +1059,28 @@ if __name__ == "__main__":
|
|
| 1038 |
logger.info(" Running without Reachy - audio playback disabled")
|
| 1039 |
logger.info(" This is normal for development/testing")
|
| 1040 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1041 |
# Run FastAPI server on port 8080 (port 8000 is used by Reachy)
|
| 1042 |
logger.info("")
|
| 1043 |
logger.info("Starting web server on http://localhost:8080")
|
| 1044 |
logger.info("Open http://localhost:8080 in your browser")
|
| 1045 |
logger.info("=" * 60)
|
| 1046 |
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
class ReachyF1Commentator(ReachyMiniApp):
|
| 47 |
"""Main Reachy Mini app for F1 commentary generation."""
|
| 48 |
|
| 49 |
+
custom_app_url: str = "http://0.0.0.0:8080" # Root path where the app is served
|
| 50 |
+
dont_start_webserver: bool = False # Let framework handle the web server
|
| 51 |
|
| 52 |
def __init__(self):
|
| 53 |
"""Initialize the F1 commentator app."""
|
|
|
|
| 79 |
logger.info("Starting F1 Commentator app")
|
| 80 |
|
| 81 |
self.reachy_mini_instance = reachy_mini
|
|
|
|
| 82 |
|
| 83 |
+
# Setup FastAPI routes using the framework-provided settings_app
|
| 84 |
+
self._setup_api_routes()
|
| 85 |
+
|
| 86 |
+
# Web server is started automatically by framework
|
| 87 |
logger.info(f"Web UI available at {self.custom_app_url}")
|
| 88 |
|
| 89 |
# Wait for stop_event or user interaction
|
|
|
|
| 95 |
finally:
|
| 96 |
self._cleanup()
|
| 97 |
|
| 98 |
+
def _setup_api_routes(self):
|
| 99 |
+
"""Setup FastAPI routes and static file serving."""
|
| 100 |
+
import os
|
| 101 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 102 |
+
from fastapi.staticfiles import StaticFiles
|
| 103 |
+
|
| 104 |
+
app = self.settings_app
|
| 105 |
+
|
| 106 |
+
# Mount static files
|
| 107 |
+
static_path = os.path.join(os.path.dirname(__file__), "static")
|
| 108 |
+
if os.path.exists(static_path):
|
| 109 |
+
app.mount("/static", StaticFiles(directory=static_path, html=True), name="static")
|
| 110 |
+
logger.info(f"Mounted static files from {static_path}")
|
| 111 |
+
|
| 112 |
+
# Initialize dashboard manager
|
| 113 |
+
global dashboard_manager
|
| 114 |
+
dashboard_manager = ConnectionManager()
|
| 115 |
+
|
| 116 |
+
# Add all API routes
|
| 117 |
+
self._add_api_routes(app)
|
| 118 |
+
|
| 119 |
+
logger.info("API routes configured")
|
| 120 |
+
|
| 121 |
+
def _add_api_routes(self, app: FastAPI):
|
| 122 |
+
"""Add all API routes to the FastAPI app."""
|
| 123 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 124 |
+
import asyncio
|
| 125 |
+
|
| 126 |
+
# Configuration endpoints
|
| 127 |
+
@app.get("/api/config")
|
| 128 |
+
async def get_config():
|
| 129 |
+
"""Get saved configuration."""
|
| 130 |
+
try:
|
| 131 |
+
config = load_saved_config()
|
| 132 |
+
return {
|
| 133 |
+
"elevenlabs_api_key": config.get("elevenlabs_api_key", ""),
|
| 134 |
+
"elevenlabs_voice_id": config.get("elevenlabs_voice_id", "HSSEHuB5EziJgTfCVmC6")
|
| 135 |
+
}
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.error(f"Failed to get config: {e}")
|
| 138 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 139 |
+
|
| 140 |
+
@app.post("/api/config")
|
| 141 |
+
async def save_config_endpoint(request: ConfigSaveRequest):
|
| 142 |
+
"""Save configuration."""
|
| 143 |
+
try:
|
| 144 |
+
config = {
|
| 145 |
+
"elevenlabs_api_key": request.elevenlabs_api_key,
|
| 146 |
+
"elevenlabs_voice_id": request.elevenlabs_voice_id
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
if save_config(config):
|
| 150 |
+
return {"status": "saved", "message": "Configuration saved successfully"}
|
| 151 |
+
else:
|
| 152 |
+
raise HTTPException(status_code=500, detail="Failed to save configuration")
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.error(f"Failed to save config: {e}")
|
| 155 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 156 |
+
|
| 157 |
+
# Race data endpoints
|
| 158 |
+
@app.get("/api/races/years")
|
| 159 |
+
async def get_years():
|
| 160 |
+
"""Get list of available years with race data."""
|
| 161 |
+
try:
|
| 162 |
+
if _app_instance is None:
|
| 163 |
+
raise HTTPException(status_code=503, detail="App not initialized")
|
| 164 |
+
|
| 165 |
+
years = _app_instance.openf1_client.get_years()
|
| 166 |
+
return {"years": years}
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Failed to get years: {e}")
|
| 169 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 170 |
+
|
| 171 |
+
@app.get("/api/races/{year}")
|
| 172 |
+
async def get_races(year: int):
|
| 173 |
+
"""Get all races for a specific year."""
|
| 174 |
+
try:
|
| 175 |
+
if _app_instance is None:
|
| 176 |
+
raise HTTPException(status_code=503, detail="App not initialized")
|
| 177 |
+
|
| 178 |
+
logger.info(f"Fetching races for year {year}")
|
| 179 |
+
races = _app_instance.openf1_client.get_races_by_year(year)
|
| 180 |
+
|
| 181 |
+
if not races:
|
| 182 |
+
logger.warning(f"No races found for year {year}")
|
| 183 |
+
return {"races": []}
|
| 184 |
+
|
| 185 |
+
# Convert to dict format
|
| 186 |
+
races_data = [
|
| 187 |
+
{
|
| 188 |
+
"session_key": race.session_key,
|
| 189 |
+
"date": race.date,
|
| 190 |
+
"country": race.country,
|
| 191 |
+
"circuit": race.circuit,
|
| 192 |
+
"name": race.name
|
| 193 |
+
}
|
| 194 |
+
for race in races
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
logger.info(f"Returning {len(races_data)} races for year {year}")
|
| 198 |
+
return {"races": races_data}
|
| 199 |
+
except Exception as e:
|
| 200 |
+
logger.error(f"Failed to get races for year {year}: {e}", exc_info=True)
|
| 201 |
+
raise HTTPException(status_code=500, detail=f"Failed to load races: {str(e)}")
|
| 202 |
+
|
| 203 |
+
# Commentary control endpoints
|
| 204 |
+
@app.post("/api/commentary/start")
|
| 205 |
+
async def start_commentary(request: CommentaryStartRequest):
|
| 206 |
+
"""Start commentary playback."""
|
| 207 |
+
try:
|
| 208 |
+
if _app_instance is None:
|
| 209 |
+
raise HTTPException(status_code=503, detail="App not initialized")
|
| 210 |
+
|
| 211 |
+
# Convert request to WebUIConfiguration
|
| 212 |
+
config = WebUIConfiguration(
|
| 213 |
+
mode=request.mode,
|
| 214 |
+
session_key=request.session_key,
|
| 215 |
+
commentary_mode=request.commentary_mode,
|
| 216 |
+
playback_speed=request.playback_speed,
|
| 217 |
+
elevenlabs_api_key=request.elevenlabs_api_key,
|
| 218 |
+
elevenlabs_voice_id=request.elevenlabs_voice_id
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
result = _app_instance.start_commentary(config)
|
| 222 |
+
return result
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error(f"Failed to start commentary: {e}")
|
| 225 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 226 |
+
|
| 227 |
+
@app.post("/api/commentary/stop")
|
| 228 |
+
async def stop_commentary():
|
| 229 |
+
"""Stop active commentary playback."""
|
| 230 |
+
try:
|
| 231 |
+
if _app_instance is None:
|
| 232 |
+
raise HTTPException(status_code=503, detail="App not initialized")
|
| 233 |
+
|
| 234 |
+
result = _app_instance.stop_commentary()
|
| 235 |
+
return result
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.error(f"Failed to stop commentary: {e}")
|
| 238 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 239 |
+
|
| 240 |
+
@app.get("/api/commentary/status")
|
| 241 |
+
async def get_status():
|
| 242 |
+
"""Get current playback status."""
|
| 243 |
+
try:
|
| 244 |
+
if _app_instance is None:
|
| 245 |
+
raise HTTPException(status_code=503, detail="App not initialized")
|
| 246 |
+
|
| 247 |
+
status = _app_instance.get_status()
|
| 248 |
+
return status
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"Failed to get status: {e}")
|
| 251 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 252 |
+
|
| 253 |
+
# Q&A endpoint
|
| 254 |
+
@app.post("/api/qa/ask")
|
| 255 |
+
async def ask_question(request: QuestionRequest):
|
| 256 |
+
"""Handle Q&A question from user."""
|
| 257 |
+
try:
|
| 258 |
+
if _app_instance is None:
|
| 259 |
+
raise HTTPException(status_code=503, detail="App not initialized")
|
| 260 |
+
|
| 261 |
+
if not request.question or not request.question.strip():
|
| 262 |
+
raise HTTPException(status_code=400, detail="Question cannot be empty")
|
| 263 |
+
|
| 264 |
+
# Process question using QA manager
|
| 265 |
+
answer = _app_instance.process_question(request.question)
|
| 266 |
+
|
| 267 |
+
return {"question": request.question, "answer": answer}
|
| 268 |
+
except Exception as e:
|
| 269 |
+
logger.error(f"Failed to process question: {e}", exc_info=True)
|
| 270 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 271 |
+
|
| 272 |
+
# Dashboard endpoints
|
| 273 |
+
@app.websocket("/ws/dashboard")
|
| 274 |
+
async def dashboard_websocket(websocket: WebSocket):
|
| 275 |
+
"""WebSocket endpoint for live dashboard updates."""
|
| 276 |
+
await dashboard_manager.connect(websocket)
|
| 277 |
+
try:
|
| 278 |
+
while True:
|
| 279 |
+
# Process any queued broadcasts
|
| 280 |
+
while dashboard_manager.broadcast_queue:
|
| 281 |
+
message = dashboard_manager.broadcast_queue.pop(0)
|
| 282 |
+
await dashboard_manager.broadcast(message)
|
| 283 |
+
|
| 284 |
+
# Wait a bit before checking queue again
|
| 285 |
+
await asyncio.sleep(0.1)
|
| 286 |
+
|
| 287 |
+
# Check if client sent any messages (for keep-alive)
|
| 288 |
+
try:
|
| 289 |
+
data = await asyncio.wait_for(websocket.receive_text(), timeout=0.1)
|
| 290 |
+
# Echo back for ping/pong
|
| 291 |
+
await websocket.send_text(data)
|
| 292 |
+
except asyncio.TimeoutError:
|
| 293 |
+
# No message received, continue
|
| 294 |
+
pass
|
| 295 |
+
|
| 296 |
+
except WebSocketDisconnect:
|
| 297 |
+
dashboard_manager.disconnect(websocket)
|
| 298 |
+
except Exception as e:
|
| 299 |
+
logger.error(f"WebSocket error: {e}")
|
| 300 |
+
dashboard_manager.disconnect(websocket)
|
| 301 |
+
|
| 302 |
+
@app.get("/api/dashboard/state")
|
| 303 |
+
async def get_dashboard_state():
|
| 304 |
+
"""Get current race state for dashboard."""
|
| 305 |
+
try:
|
| 306 |
+
if _app_instance is None or _app_instance.state_tracker is None:
|
| 307 |
+
return {
|
| 308 |
+
"positions": [],
|
| 309 |
+
"race_info": {
|
| 310 |
+
"current_lap": 0,
|
| 311 |
+
"total_laps": 0,
|
| 312 |
+
"leader": None,
|
| 313 |
+
"fastest_lap_holder": None,
|
| 314 |
+
"race_phase": "START"
|
| 315 |
+
},
|
| 316 |
+
"recent_events": []
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
# Get positions
|
| 320 |
+
positions = _app_instance.state_tracker.get_positions()
|
| 321 |
+
positions_data = [
|
| 322 |
+
{
|
| 323 |
+
"position": p.position,
|
| 324 |
+
"driver": p.name,
|
| 325 |
+
"gap_to_leader": f"+{p.gap_to_leader:.3f}s" if p.gap_to_leader > 0 else "Leader",
|
| 326 |
+
"tire_compound": p.current_tire or "Unknown",
|
| 327 |
+
"pit_stops": p.pit_count,
|
| 328 |
+
"team": "Unknown" # Team info not available in DriverState
|
| 329 |
+
}
|
| 330 |
+
for p in positions[:20] # Top 20
|
| 331 |
+
]
|
| 332 |
+
|
| 333 |
+
# Get race info
|
| 334 |
+
leader = _app_instance.state_tracker.get_leader()
|
| 335 |
+
state = _app_instance.state_tracker._state # Access private _state attribute
|
| 336 |
+
|
| 337 |
+
race_info = {
|
| 338 |
+
"current_lap": state.current_lap,
|
| 339 |
+
"total_laps": state.total_laps,
|
| 340 |
+
"leader": leader.name if leader else None,
|
| 341 |
+
"fastest_lap_holder": state.fastest_lap_driver,
|
| 342 |
+
"fastest_lap_time": f"{state.fastest_lap_time:.3f}s" if state.fastest_lap_time else None,
|
| 343 |
+
"race_phase": state.race_phase.value if state.race_phase else "START",
|
| 344 |
+
"safety_car": state.safety_car_active
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
return {
|
| 348 |
+
"positions": positions_data,
|
| 349 |
+
"race_info": race_info,
|
| 350 |
+
"recent_events": [] # TODO: Add event history
|
| 351 |
+
}
|
| 352 |
+
except Exception as e:
|
| 353 |
+
logger.error(f"Failed to get dashboard state: {e}", exc_info=True)
|
| 354 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 355 |
+
|
| 356 |
+
# Root and health endpoints
|
| 357 |
+
@app.get("/")
|
| 358 |
+
async def root():
|
| 359 |
+
"""Redirect to static UI."""
|
| 360 |
+
return RedirectResponse(url="/static/index.html")
|
| 361 |
+
|
| 362 |
+
@app.get("/health")
|
| 363 |
+
async def health():
|
| 364 |
+
"""Health check endpoint."""
|
| 365 |
+
return {"status": "healthy", "app": "reachy-f1-commentator"}
|
| 366 |
+
|
| 367 |
def start_commentary(self, config: WebUIConfiguration) -> dict:
|
| 368 |
"""
|
| 369 |
Start commentary playback with given configuration.
|
|
|
|
| 920 |
|
| 921 |
|
| 922 |
|
| 923 |
+
# ============================================================================
|
| 924 |
+
# Helper Classes and Functions for API Routes
|
| 925 |
+
# ============================================================================
|
| 926 |
|
| 927 |
# WebSocket connections for live dashboard
|
| 928 |
from fastapi import WebSocket, WebSocketDisconnect
|
|
|
|
| 965 |
for conn in disconnected:
|
| 966 |
self.disconnect(conn)
|
| 967 |
|
| 968 |
+
# Global dashboard manager (initialized in _setup_api_routes)
|
| 969 |
+
dashboard_manager = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
|
| 971 |
|
| 972 |
# Pydantic models for API
|
|
|
|
| 984 |
elevenlabs_voice_id: str = 'HSSEHuB5EziJgTfCVmC6'
|
| 985 |
|
| 986 |
|
| 987 |
+
class QuestionRequest(BaseModel):
|
| 988 |
+
question: str
|
| 989 |
+
|
| 990 |
+
|
| 991 |
# Configuration file path
|
| 992 |
+
import os
|
| 993 |
CONFIG_DIR = os.path.expanduser("~/.reachy_f1_commentator")
|
| 994 |
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
| 995 |
|
|
|
|
| 1020 |
return False
|
| 1021 |
|
| 1022 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
# ============================================================================
|
| 1024 |
# Standalone Mode Entry Point
|
| 1025 |
# ============================================================================
|
|
|
|
| 1034 |
The app will auto-detect and connect to Reachy if available.
|
| 1035 |
"""
|
| 1036 |
import uvicorn
|
| 1037 |
+
from fastapi import FastAPI
|
| 1038 |
|
| 1039 |
logger.info("=" * 60)
|
| 1040 |
logger.info("Starting Reachy F1 Commentator in standalone mode")
|
|
|
|
| 1059 |
logger.info(" Running without Reachy - audio playback disabled")
|
| 1060 |
logger.info(" This is normal for development/testing")
|
| 1061 |
|
| 1062 |
+
# Create a standalone FastAPI app for development
|
| 1063 |
+
standalone_app = FastAPI(title="Reachy F1 Commentator API (Standalone)")
|
| 1064 |
+
|
| 1065 |
+
# Manually set up the app's settings_app to use our standalone app
|
| 1066 |
+
commentator.settings_app = standalone_app
|
| 1067 |
+
|
| 1068 |
+
# Setup API routes
|
| 1069 |
+
commentator._setup_api_routes()
|
| 1070 |
+
|
| 1071 |
# Run FastAPI server on port 8080 (port 8000 is used by Reachy)
|
| 1072 |
logger.info("")
|
| 1073 |
logger.info("Starting web server on http://localhost:8080")
|
| 1074 |
logger.info("Open http://localhost:8080 in your browser")
|
| 1075 |
logger.info("=" * 60)
|
| 1076 |
|
| 1077 |
+
try:
|
| 1078 |
+
uvicorn.run(
|
| 1079 |
+
standalone_app,
|
| 1080 |
+
host="0.0.0.0",
|
| 1081 |
+
port=8080,
|
| 1082 |
+
log_level="info"
|
| 1083 |
+
)
|
| 1084 |
+
except KeyboardInterrupt:
|
| 1085 |
+
logger.info("Shutting down...")
|
| 1086 |
+
commentator._cleanup()
|
reachy_f1_commentator/static/dashboard.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>Live Race Dashboard - Reachy F1 Commentator</title>
|
| 7 |
-
<link rel="stylesheet" href="dashboard.css">
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="dashboard-container">
|
|
@@ -69,6 +69,6 @@
|
|
| 69 |
</footer>
|
| 70 |
</div>
|
| 71 |
|
| 72 |
-
<script src="dashboard.js"></script>
|
| 73 |
</body>
|
| 74 |
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>Live Race Dashboard - Reachy F1 Commentator</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/dashboard.css">
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="dashboard-container">
|
|
|
|
| 69 |
</footer>
|
| 70 |
</div>
|
| 71 |
|
| 72 |
+
<script src="/static/dashboard.js"></script>
|
| 73 |
</body>
|
| 74 |
</html>
|
reachy_f1_commentator/static/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>Reachy F1 Commentator</title>
|
| 7 |
-
<link rel="stylesheet" href="style.css">
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="container">
|
|
@@ -14,7 +14,7 @@
|
|
| 14 |
<h1>๐๏ธ Reachy F1 Commentator</h1>
|
| 15 |
<p class="subtitle">Interactive F1 Race Commentary</p>
|
| 16 |
</div>
|
| 17 |
-
<a href="dashboard.html" class="dashboard-link" target="_blank">
|
| 18 |
๐ Live Dashboard
|
| 19 |
</a>
|
| 20 |
</div>
|
|
@@ -142,6 +142,6 @@
|
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
|
| 145 |
-
<script src="main.js"></script>
|
| 146 |
</body>
|
| 147 |
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>Reachy F1 Commentator</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="container">
|
|
|
|
| 14 |
<h1>๐๏ธ Reachy F1 Commentator</h1>
|
| 15 |
<p class="subtitle">Interactive F1 Race Commentary</p>
|
| 16 |
</div>
|
| 17 |
+
<a href="/static/dashboard.html" class="dashboard-link" target="_blank">
|
| 18 |
๐ Live Dashboard
|
| 19 |
</a>
|
| 20 |
</div>
|
|
|
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
|
| 145 |
+
<script src="/static/main.js"></script>
|
| 146 |
</body>
|
| 147 |
</html>
|