Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ from opengsq.protocols import (
Kaillera,
KillingFloor,
Minecraft,
Nadeo,
Palworld,
Quake1,
Quake2,
Expand Down
15 changes: 14 additions & 1 deletion opengsq/protocol_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ def __init__(self, kind: SocketKind):
self.__protocol = None
self.__kind = kind

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc_value, traceback):
self.close()

def __enter__(self):
return self

Expand Down Expand Up @@ -59,7 +65,14 @@ def send(self, data: bytes):
else:
self.__transport.sendto(data)

async def recv(self) -> bytes:
async def recv(self, size: int = None) -> bytes:
if size:
data = b""
while len(data) < size:
chunk = await self.__protocol.recv()
data += chunk
if len(data) >= size:
return data[:size]
return await self.__protocol.recv()

class __Protocol(asyncio.Protocol):
Expand Down
1 change: 1 addition & 0 deletions opengsq/protocols/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from opengsq.protocols.kaillera import Kaillera
from opengsq.protocols.killingfloor import KillingFloor
from opengsq.protocols.minecraft import Minecraft
from opengsq.protocols.nadeo import Nadeo
from opengsq.protocols.palworld import Palworld
from opengsq.protocols.quake1 import Quake1
from opengsq.protocols.quake2 import Quake2
Expand Down
96 changes: 96 additions & 0 deletions opengsq/protocols/nadeo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
from __future__ import annotations

import asyncio
import struct
import xmlrpc.client as xmlrpclib
from typing import Any, Optional

from opengsq.exceptions import InvalidPacketException
from opengsq.protocol_base import ProtocolBase
from opengsq.responses.nadeo import Status


class Nadeo(ProtocolBase):
full_name = "Nadeo GBXRemote Protocol"
INITIAL_HANDLER = 0x80000000
MAXIMUM_HANDLER = 0xFFFFFFFF

def __init__(self, host: str, port: int = 5000, timeout: float = 5.0):
super().__init__(host, port, timeout)
self.handler = self.MAXIMUM_HANDLER
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None

async def connect(self) -> None:
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)

# Read and validate header
data = await self._reader.read(4)
header_length = struct.unpack('<I', data)[0]

data = await self._reader.read(header_length)
header = data.decode()

if header != "GBXRemote 2":
raise InvalidPacketException('No "GBXRemote 2" header found!')

async def close(self) -> None:
if self._writer:
self._writer.close()
await self._writer.wait_closed()

async def __aenter__(self):
await self.connect()
return self

async def __aexit__(self, exc_type, exc_value, traceback):
await self.close()

async def _execute(self, method: str, *args) -> Any:
if self.handler == self.MAXIMUM_HANDLER:
self.handler = self.INITIAL_HANDLER
else:
self.handler += 1

handler_bytes = self.handler.to_bytes(4, byteorder='little')
data = xmlrpclib.dumps(args, method).encode()
packet_len = len(data)

packet = packet_len.to_bytes(4, byteorder='little') + handler_bytes + data

self._writer.write(packet)
await self._writer.drain()

# Read response
header = await self._reader.read(8)
size = struct.unpack('<I', header[:4])[0]
handler = struct.unpack('<I', header[4:8])[0]

if handler != self.handler:
raise InvalidPacketException(f'Handler mismatch: {handler} != {self.handler}')

data = await self._reader.readexactly(size)

try:
response = xmlrpclib.loads(data.decode())
return response[0][0] if response else None
except xmlrpclib.Fault as e:
raise InvalidPacketException(f'RPC Fault: {e}')

async def authenticate(self, username: str, password: str) -> bool:
await self.connect()
result = await self._execute('Authenticate', username, password)
return bool(result)

async def get_status(self) -> Status:
version = await self._execute('GetVersion')
server_info = await self._execute('GetServerOptions')
player_list = await self._execute('GetPlayerList', 100, 0)
current_map = await self._execute('GetCurrentChallengeInfo')

return Status.from_raw_data(
version_data=version,
server_data=server_info,
players_data=player_list,
map_data=current_map
)
1 change: 1 addition & 0 deletions opengsq/responses/nadeo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .status import Status
102 changes: 102 additions & 0 deletions opengsq/responses/nadeo/status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional


@dataclass
class Player:
"""Represents a player on the server."""
login: str
nickname: str
player_id: int
team_id: int
is_spectator: bool
ladder_ranking: int
flags: int


@dataclass
class ServerOptions:
"""Represents server configuration options."""
name: str
comment: str
password: bool
max_players: int
max_spectators: int
current_game_mode: int
current_chat_time: int
hide_server: int
ladder_mode: int
vehicle_quality: int


@dataclass
class Version:
"""Represents the server version information."""
name: str
version: str
build: str


@dataclass
class Status:
version: Version
server_options: ServerOptions
players: list[Player]
map_info: MapInfo # Add this

@classmethod
def from_raw_data(cls, version_data: dict[str, str],
server_data: dict[str, Any],
players_data: list[dict[str, Any]],
map_data: dict[str, Any]) -> Status:
version = Version(
name=version_data.get('Name', ''),
version=version_data.get('Version', ''),
build=version_data.get('Build', '')
)

server_options = ServerOptions(
name=server_data.get('Name', ''),
comment=server_data.get('Comment', ''),
password=server_data.get('Password', False),
max_players=server_data.get('CurrentMaxPlayers', 0),
max_spectators=server_data.get('CurrentMaxSpectators', 0),
current_game_mode=server_data.get('CurrentGameMode', 0),
current_chat_time=server_data.get('CurrentChatTime', 0),
hide_server=server_data.get('HideServer', 0),
ladder_mode=server_data.get('CurrentLadderMode', 0),
vehicle_quality=server_data.get('CurrentVehicleNetQuality', 0)
)

players = [
Player(
login=p.get('Login', ''),
nickname=p.get('NickName', ''),
player_id=p.get('PlayerId', -1),
team_id=p.get('TeamId', -1),
is_spectator=p.get('IsSpectator', False),
ladder_ranking=p.get('LadderRanking', 0),
flags=p.get('Flags', 0)
)
for p in players_data
]

map_info = MapInfo.from_dict(map_data)

return cls(version, server_options, players, map_info)

@dataclass
class MapInfo:
"""Represents current map information."""
name: str
author: str
environment: str

@classmethod
def from_dict(cls, data: dict[str, Any]) -> MapInfo:
return cls(
name=data.get('Name', ''),
author=data.get('Author', ''),
environment=data.get('Environment', '')
)
40 changes: 40 additions & 0 deletions tests/protocols/test_nadeo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import pytest
from opengsq.protocols.nadeo import Nadeo

from ..result_handler import ResultHandler

handler = ResultHandler(__file__)
# handler.enable_save = True

# Test server configuration
SERVER_IP = "192.168.100.2"
SERVER_PORT = 5000
SERVER_USER = "SuperAdmin"
SERVER_PASSWORD = "SuperAdmin"

nadeo = Nadeo(host=SERVER_IP, port=SERVER_PORT)


@pytest.mark.asyncio
async def test_authenticate():
result = await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD)
assert result is True


@pytest.mark.asyncio
async def test_get_status():
await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD)
result = await nadeo.get_status()
await handler.save_result("test_get_status", result)


@pytest.mark.asyncio
async def test_get_map_info():
await nadeo.authenticate(SERVER_USER, SERVER_PASSWORD)
result = await nadeo.get_status()
print(f"Server: {result.server_options.name}")
print(f"Map: {result.map_info.name}")
print(f"Author: {result.map_info.author}")
print(f"Players: {len(result.players)}/{result.server_options.max_players}")
assert result.map_info.name != ""
assert result.server_options.name != ""