Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 214 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from __future__ import annotations

import asyncio
from contextlib import asynccontextmanager
from math import isfinite
from pathlib import Path
import socket
import threading
import ctypes
from typing import Any, Literal

from fastapi import FastAPI
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, field_validator

from .compute import compute_positions


FIELD_SIZES_PATH = Path(__file__).resolve().parents[2] / "src" / "config" / "field_sizes.json"


Expand Down Expand Up @@ -69,7 +73,198 @@ class ComputePositionsResponse(BaseModel):
warnings: list[str]


app = FastAPI(title="Formation Backend", version="0.1.0")
# GameController binary packet ctypes structures (corresponds to RoboCupGameControlData version 20)
class RobotInfo(ctypes.Structure):
_fields_ = [
("penalty", ctypes.c_uint8),
("secsTillUnpenalised", ctypes.c_uint8),
("cautions", ctypes.c_uint8),
]


class TeamInfo(ctypes.Structure):
_fields_ = [
("teamNumber", ctypes.c_uint8),
("fieldPlayerColour", ctypes.c_uint8),
("goalkeeperColour", ctypes.c_uint8),
("goalkeeper", ctypes.c_uint8),
("score", ctypes.c_uint8),
("penaltyShot", ctypes.c_uint8),
("singleShots", ctypes.c_uint16),
("messageBudget", ctypes.c_uint16),
("players", RobotInfo * 20),
]


class RoboCupGameControlData(ctypes.Structure):
_pack_ = 1
_fields_ = [
("header", ctypes.c_char * 4),
("version", ctypes.c_uint8),
("packetNumber", ctypes.c_uint8),
("playersPerTeam", ctypes.c_uint8),
("competitionType", ctypes.c_uint8),
("stopped", ctypes.c_uint8),
("gamePhase", ctypes.c_uint8),
("state", ctypes.c_uint8),
("setPlay", ctypes.c_uint8),
("firstHalf", ctypes.c_uint8),
("kickingTeam", ctypes.c_uint8),
("secsRemaining", ctypes.c_int16),
("secondaryTime", ctypes.c_int16),
("teams", TeamInfo * 2),
]


PACKET_SIZE = ctypes.sizeof(RoboCupGameControlData) # 158 bytes

latest_gc_state: dict[str, Any] = {
"gamePhase": "normal",
"state": "playing",
"setPlay": "none",
"firstHalf": True,
"stopped": False,
"ownTeamNumber": 0,
"kickingTeam": None,
"teamNumbers": [0, 0],
}

latest_gc_lock = threading.Lock()
stop_event = threading.Event()


class ConnectionManager:
def __init__(self):
self.active_connections: list[WebSocket] = []

async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
with latest_gc_lock:
state_to_send = latest_gc_state.copy()
await websocket.send_json(state_to_send)

def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)

async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception:
pass


manager = ConnectionManager()


def parse_gc_data(data: bytes) -> dict[str, Any] | None:
if len(data) < PACKET_SIZE:
return None

packet = RoboCupGameControlData.from_buffer_copy(data[:PACKET_SIZE])

if packet.header not in (b"RGme", b"RGTD"):
return None
if packet.version != 20:
return None

GAME_PHASES = ["normal", "penalty_shoot_out", "extra_time", "timeout"]
STATES = ["initial", "ready", "set", "playing", "finished"]
SET_PLAYS = [
"none",
"direct_free_kick",
"indirect_free_kick",
"penalty_kick",
"throw_in",
"goal_kick",
"corner_kick",
]

game_phase_str = (
GAME_PHASES[packet.gamePhase] if packet.gamePhase < len(GAME_PHASES) else "normal"
)
state_str = STATES[packet.state] if packet.state < len(STATES) else "playing"
set_play_str = SET_PLAYS[packet.setPlay] if packet.setPlay < len(SET_PLAYS) else "none"

return {
"gamePhase": game_phase_str,
"state": state_str,
"setPlay": set_play_str,
"firstHalf": bool(packet.firstHalf),
"stopped": bool(packet.stopped),
"kickingTeam": None if packet.kickingTeam == 255 else packet.kickingTeam,
"teamNumbers": [packet.teams[0].teamNumber, packet.teams[1].teamNumber],
}



def udp_listener_loop(loop: asyncio.AbstractEventLoop):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except AttributeError:
pass

try:
sock.bind(("", 3838))
except Exception as e:
print(f"GameController: Failed to bind to port 3838: {e}")
return

sock.settimeout(0.5)

while not stop_event.is_set():
try:
data, addr = sock.recvfrom(1024)
parsed = parse_gc_data(data)
if parsed is not None:
with latest_gc_lock:
latest_gc_state.update({
"gamePhase": parsed["gamePhase"],
"state": parsed["state"],
"setPlay": parsed["setPlay"],
"firstHalf": parsed["firstHalf"],
"stopped": parsed["stopped"],
"kickingTeam": parsed["kickingTeam"],
"teamNumbers": parsed["teamNumbers"],
})
state_copy = latest_gc_state.copy()

asyncio.run_coroutine_threadsafe(
manager.broadcast(state_copy), loop
)

if data.startswith(b"RGme"):
gc_ip = addr[0]
try:
sock.sendto(b"RGTr\x00", (gc_ip, 3636))
except Exception as e:
print(f"GameController: Failed to send monitor request to {gc_ip}: {e}")

except socket.timeout:
continue
except Exception as e:
if not stop_event.is_set():
print(f"GameController: Error in UDP listener: {e}")
break
sock.close()


@asynccontextmanager
async def lifespan(app: FastAPI):
loop = asyncio.get_running_loop()
stop_event.clear()
thread = threading.Thread(target=udp_listener_loop, args=(loop,), daemon=True)
thread.start()
yield
stop_event.set()
thread.join(timeout=1.0)


app = FastAPI(title="Formation Backend", version="0.1.0", lifespan=lifespan)

app.add_middleware(
CORSMiddleware,
Expand All @@ -86,6 +281,22 @@ def health() -> dict[str, str]:
return {"status": "ok"}


@app.get("/api/gamecontroller/state")
def get_gamecontroller_state() -> dict[str, Any]:
with latest_gc_lock:
return latest_gc_state.copy()


@app.websocket("/api/gamecontroller/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)


@app.post("/api/compute_positions", response_model=ComputePositionsResponse)
def post_compute_positions(
request: ComputePositionsRequest,
Expand Down
20 changes: 18 additions & 2 deletions backend/app/test_compute.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
from __future__ import annotations

import struct
import unittest

from app.compute import compute_positions
from app.main import parse_gc_data


class ComputePositionsTests(unittest.TestCase):
def test_parse_gc_data(self) -> None:
header = struct.pack("<4sBBBBBBBBBBhh", b"RGme", 20, 0, 5, 0, 0, 0, 3, 0, 1, 7, 300, 10)
team1 = struct.pack("<BBBBBBHH60s", 7, 0, 0, 0, 2, 0, 0, 0, b"\x00" * 60)
team2 = struct.pack("<BBBBBBHH60s", 8, 0, 0, 0, 1, 0, 0, 0, b"\x00" * 60)

packet = header + team1 + team2
parsed = parse_gc_data(packet)
self.assertIsNotNone(parsed)
self.assertEqual(parsed["gamePhase"], "normal")
self.assertEqual(parsed["state"], "playing")
self.assertEqual(parsed["setPlay"], "none")
self.assertEqual(parsed["firstHalf"], True)
self.assertEqual(parsed["stopped"], False)
self.assertEqual(parsed["kickingTeam"], 7)
self.assertEqual(parsed["teamNumbers"], [7, 8])

def test_exact_key_precedes_legacy_mode(self) -> None:
positions, warnings = compute_positions(
game_controller_state={
Expand Down
67 changes: 67 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,73 @@ function App() {
resolvedMode.legacyMode,
])

useEffect(() => {
let socket: WebSocket | null = null
let reconnectTimeoutId: number | null = null
let isCleanup = false

function connect() {
if (isCleanup) return

const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const url = `${protocol}//${host}/api/gamecontroller/ws`

socket = new WebSocket(url)

socket.onmessage = (event) => {
try {
const received = JSON.parse(event.data)
if (received && typeof received === 'object') {
setGameControllerState((prev) => {
const teamNumbers = Array.isArray(received.teamNumbers) ? received.teamNumbers : []
const ownTeamNumber =
prev.ownTeamNumber === 0 || !teamNumbers.includes(prev.ownTeamNumber)
? (teamNumbers[0] ?? prev.ownTeamNumber)
: prev.ownTeamNumber

return {
...prev,
gamePhase: received.gamePhase ?? prev.gamePhase,
state: received.state ?? prev.state,
setPlay: received.setPlay ?? prev.setPlay,
firstHalf: typeof received.firstHalf === 'boolean' ? received.firstHalf : prev.firstHalf,
stopped: typeof received.stopped === 'boolean' ? received.stopped : prev.stopped,
kickingTeam: received.kickingTeam !== undefined ? received.kickingTeam : prev.kickingTeam,
ownTeamNumber,
}
})
}
} catch (err) {
console.error('Failed to parse GameController websocket message:', err)
}
}

socket.onclose = () => {
if (!isCleanup) {
reconnectTimeoutId = window.setTimeout(connect, 3000)
}
}

socket.onerror = () => {
socket?.close()
}
}

connect()

return () => {
isCleanup = true
if (socket) {
socket.close()
}
if (reconnectTimeoutId !== null) {
window.clearTimeout(reconnectTimeoutId)
}
}
}, [])


const selectedRobotLimits =
appMode === 'record'
? getPlayerLimits(recordingDraft, activeModeKey, selectedRobotId, dimensions)
Expand Down
5 changes: 4 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8000',
'/api': {
target: 'http://127.0.0.1:8000',
ws: true,
},
},
},
test: {
Expand Down