rlstatsapi 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlstatsapi-0.1.2/PKG-INFO +112 -0
- rlstatsapi-0.1.2/README.md +93 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/pyproject.toml +30 -30
- rlstatsapi-0.1.2/src/rlstatsapi/__init__.py +25 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/src/rlstatsapi/client.py +62 -30
- rlstatsapi-0.1.2/src/rlstatsapi/types.py +373 -0
- rlstatsapi-0.1.2/src/rlstatsapi.egg-info/PKG-INFO +112 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/src/rlstatsapi.egg-info/SOURCES.txt +1 -1
- rlstatsapi-0.1.0/PKG-INFO +0 -74
- rlstatsapi-0.1.0/README.md +0 -55
- rlstatsapi-0.1.0/src/rlstatsapi/__init__.py +0 -7
- rlstatsapi-0.1.0/src/rlstatsapi/_websocket.py +0 -176
- rlstatsapi-0.1.0/src/rlstatsapi.egg-info/PKG-INFO +0 -74
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/LICENSE +0 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/setup.cfg +0 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/src/rlstatsapi/events.py +0 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/src/rlstatsapi/models.py +0 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/src/rlstatsapi.egg-info/dependency_links.txt +0 -0
- {rlstatsapi-0.1.0 → rlstatsapi-0.1.2}/src/rlstatsapi.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rlstatsapi
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Rocket League Stats API client
|
|
5
|
+
Author: Manuel Cabral
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/manucabral/RocketLeagueStatsAPI
|
|
8
|
+
Keywords: rocket-league,stats,socket,asyncio
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Games/Entertainment
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# RL Stats API
|
|
21
|
+
|
|
22
|
+
`rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
From PyPI:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install rlstatsapi
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
From GitHub (latest `main`):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install git+https://github.com/manucabral/RocketLeagueStatsAPI.git
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Rocket League setup
|
|
39
|
+
|
|
40
|
+
Before launching Rocket League, edit:
|
|
41
|
+
|
|
42
|
+
`<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
|
|
43
|
+
|
|
44
|
+
Use at least:
|
|
45
|
+
|
|
46
|
+
- `PacketSendRate=30` (any value `> 0` enables the exporter)
|
|
47
|
+
- `Port=49123`
|
|
48
|
+
|
|
49
|
+
Restart the game after changing the file.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
import logging
|
|
56
|
+
from rlstatsapi import StatsClient
|
|
57
|
+
|
|
58
|
+
logging.basicConfig(level=logging.INFO)
|
|
59
|
+
|
|
60
|
+
async def main() -> None:
|
|
61
|
+
client = StatsClient(log_enabled=True)
|
|
62
|
+
client.on_any(lambda msg: print(msg.event, msg.data))
|
|
63
|
+
|
|
64
|
+
await client.connect()
|
|
65
|
+
try:
|
|
66
|
+
await asyncio.Event().wait()
|
|
67
|
+
finally:
|
|
68
|
+
await client.disconnect()
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Example Event (Goals)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
from rlstatsapi import StatsClient
|
|
78
|
+
from rlstatsapi.models import EventMessage
|
|
79
|
+
from rlstatsapi.types import GoalScoredPayload, cast_event_data
|
|
80
|
+
|
|
81
|
+
async def on_goal(msg: EventMessage) -> None:
|
|
82
|
+
data: GoalScoredPayload = cast_event_data("GoalScored", msg.data)
|
|
83
|
+
scorer = data.get("Scorer", {})
|
|
84
|
+
print("Goal by:", scorer.get("Name"))
|
|
85
|
+
|
|
86
|
+
async def main() -> None:
|
|
87
|
+
client = StatsClient()
|
|
88
|
+
client.on("GoalScored", on_goal)
|
|
89
|
+
await client.connect()
|
|
90
|
+
try:
|
|
91
|
+
await asyncio.Event().wait()
|
|
92
|
+
finally:
|
|
93
|
+
await client.disconnect()
|
|
94
|
+
|
|
95
|
+
asyncio.run(main())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
## Public API
|
|
100
|
+
|
|
101
|
+
- `StatsClient(...)`
|
|
102
|
+
- `connect()` / `disconnect()`
|
|
103
|
+
- `on(event_name, handler)`
|
|
104
|
+
- `on_any(handler)`
|
|
105
|
+
- `events()`
|
|
106
|
+
- typed helpers: `EventName`, `TypedEventMessage[...]`, `cast_event_data(...)`
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
## Notes
|
|
110
|
+
|
|
111
|
+
- Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
|
|
112
|
+
- In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# RL Stats API
|
|
2
|
+
|
|
3
|
+
`rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
From PyPI:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install rlstatsapi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
From GitHub (latest `main`):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install git+https://github.com/manucabral/RocketLeagueStatsAPI.git
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Rocket League setup
|
|
20
|
+
|
|
21
|
+
Before launching Rocket League, edit:
|
|
22
|
+
|
|
23
|
+
`<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
|
|
24
|
+
|
|
25
|
+
Use at least:
|
|
26
|
+
|
|
27
|
+
- `PacketSendRate=30` (any value `> 0` enables the exporter)
|
|
28
|
+
- `Port=49123`
|
|
29
|
+
|
|
30
|
+
Restart the game after changing the file.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import asyncio
|
|
36
|
+
import logging
|
|
37
|
+
from rlstatsapi import StatsClient
|
|
38
|
+
|
|
39
|
+
logging.basicConfig(level=logging.INFO)
|
|
40
|
+
|
|
41
|
+
async def main() -> None:
|
|
42
|
+
client = StatsClient(log_enabled=True)
|
|
43
|
+
client.on_any(lambda msg: print(msg.event, msg.data))
|
|
44
|
+
|
|
45
|
+
await client.connect()
|
|
46
|
+
try:
|
|
47
|
+
await asyncio.Event().wait()
|
|
48
|
+
finally:
|
|
49
|
+
await client.disconnect()
|
|
50
|
+
|
|
51
|
+
asyncio.run(main())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Example Event (Goals)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from rlstatsapi import StatsClient
|
|
59
|
+
from rlstatsapi.models import EventMessage
|
|
60
|
+
from rlstatsapi.types import GoalScoredPayload, cast_event_data
|
|
61
|
+
|
|
62
|
+
async def on_goal(msg: EventMessage) -> None:
|
|
63
|
+
data: GoalScoredPayload = cast_event_data("GoalScored", msg.data)
|
|
64
|
+
scorer = data.get("Scorer", {})
|
|
65
|
+
print("Goal by:", scorer.get("Name"))
|
|
66
|
+
|
|
67
|
+
async def main() -> None:
|
|
68
|
+
client = StatsClient()
|
|
69
|
+
client.on("GoalScored", on_goal)
|
|
70
|
+
await client.connect()
|
|
71
|
+
try:
|
|
72
|
+
await asyncio.Event().wait()
|
|
73
|
+
finally:
|
|
74
|
+
await client.disconnect()
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
## Public API
|
|
81
|
+
|
|
82
|
+
- `StatsClient(...)`
|
|
83
|
+
- `connect()` / `disconnect()`
|
|
84
|
+
- `on(event_name, handler)`
|
|
85
|
+
- `on_any(handler)`
|
|
86
|
+
- `events()`
|
|
87
|
+
- typed helpers: `EventName`, `TypedEventMessage[...]`, `cast_event_data(...)`
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
|
|
93
|
+
- In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=69", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "rlstatsapi"
|
|
7
|
-
version = "0.1.
|
|
8
|
-
description = "Rocket League Stats API client"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.11"
|
|
11
|
-
license = { text = "MIT" }
|
|
12
|
-
authors = [{ name = "Manuel Cabral" }]
|
|
13
|
-
keywords = ["rocket-league", "stats", "
|
|
14
|
-
classifiers = [
|
|
15
|
-
"Programming Language :: Python :: 3",
|
|
16
|
-
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
-
"License :: OSI Approved :: MIT License",
|
|
18
|
-
"Operating System :: OS Independent",
|
|
19
|
-
"Topic :: Games/Entertainment",
|
|
20
|
-
"Topic :: Software Development :: Libraries",
|
|
21
|
-
]
|
|
22
|
-
|
|
23
|
-
[project.urls]
|
|
24
|
-
Homepage = "https://github.com/manucabral/RocketLeagueStatsAPI"
|
|
25
|
-
|
|
26
|
-
[tool.setuptools]
|
|
27
|
-
package-dir = {"" = "src"}
|
|
28
|
-
|
|
29
|
-
[tool.setuptools.packages.find]
|
|
30
|
-
where = ["src"]
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rlstatsapi"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Rocket League Stats API client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Manuel Cabral" }]
|
|
13
|
+
keywords = ["rocket-league", "stats", "socket", "asyncio"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Games/Entertainment",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/manucabral/RocketLeagueStatsAPI"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
package-dir = {"" = "src"}
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""rlstatsapi public API."""
|
|
2
|
+
|
|
3
|
+
from .client import StatsClient
|
|
4
|
+
from .events import KNOWN_EVENTS
|
|
5
|
+
from .models import EventMessage
|
|
6
|
+
from .types import (
|
|
7
|
+
EventName,
|
|
8
|
+
GoalScoredPayload,
|
|
9
|
+
KnownEventMessage,
|
|
10
|
+
TypedEventMessage,
|
|
11
|
+
UpdateStatePayload,
|
|
12
|
+
cast_event_data,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"StatsClient",
|
|
17
|
+
"EventMessage",
|
|
18
|
+
"KNOWN_EVENTS",
|
|
19
|
+
"EventName",
|
|
20
|
+
"TypedEventMessage",
|
|
21
|
+
"KnownEventMessage",
|
|
22
|
+
"UpdateStatePayload",
|
|
23
|
+
"GoalScoredPayload",
|
|
24
|
+
"cast_event_data",
|
|
25
|
+
]
|
|
@@ -9,7 +9,6 @@ from collections import defaultdict
|
|
|
9
9
|
from typing import Any, AsyncIterator, Awaitable, Callable
|
|
10
10
|
|
|
11
11
|
from .models import EventMessage
|
|
12
|
-
from ._websocket import SimpleWebSocket, connect_websocket
|
|
13
12
|
|
|
14
13
|
Handler = Callable[[EventMessage], Awaitable[None] | None]
|
|
15
14
|
|
|
@@ -41,7 +40,8 @@ class StatsClient:
|
|
|
41
40
|
self._handlers_any: list[Handler] = []
|
|
42
41
|
self._logger = logging.getLogger("rlstatsapi")
|
|
43
42
|
|
|
44
|
-
self.
|
|
43
|
+
self._reader: asyncio.StreamReader | None = None
|
|
44
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
45
45
|
self._reader_task: asyncio.Task[None] | None = None
|
|
46
46
|
self._stopping = False
|
|
47
47
|
|
|
@@ -58,10 +58,12 @@ class StatsClient:
|
|
|
58
58
|
self._stopping = True
|
|
59
59
|
self._log("disconnect requested")
|
|
60
60
|
|
|
61
|
-
if self.
|
|
61
|
+
if self._writer is not None:
|
|
62
62
|
with contextlib.suppress(Exception):
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
self._writer.close()
|
|
64
|
+
await self._writer.wait_closed()
|
|
65
|
+
self._writer = None
|
|
66
|
+
self._reader = None
|
|
65
67
|
|
|
66
68
|
if self._reader_task is not None:
|
|
67
69
|
self._reader_task.cancel()
|
|
@@ -83,14 +85,13 @@ class StatsClient:
|
|
|
83
85
|
async def _run(self) -> None:
|
|
84
86
|
while not self._stopping:
|
|
85
87
|
try:
|
|
86
|
-
self._log(f"connecting to
|
|
87
|
-
self.
|
|
88
|
-
host=self.host,
|
|
89
|
-
port=self.port,
|
|
88
|
+
self._log(f"connecting to tcp://{self.host}:{self.port}")
|
|
89
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
90
|
+
asyncio.open_connection(host=self.host, port=self.port),
|
|
90
91
|
timeout=self.connect_timeout,
|
|
91
92
|
)
|
|
92
|
-
self._log("
|
|
93
|
-
await self._read_loop(self.
|
|
93
|
+
self._log("tcp socket connected")
|
|
94
|
+
await self._read_loop(self._reader)
|
|
94
95
|
except asyncio.CancelledError:
|
|
95
96
|
self._log("reader task cancelled")
|
|
96
97
|
raise
|
|
@@ -102,22 +103,48 @@ class StatsClient:
|
|
|
102
103
|
self._log(f"reconnecting in {self.reconnect_delay:.2f}s")
|
|
103
104
|
await asyncio.sleep(self.reconnect_delay)
|
|
104
105
|
finally:
|
|
105
|
-
if self.
|
|
106
|
+
if self._writer is not None:
|
|
106
107
|
with contextlib.suppress(Exception):
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
self.
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
self._writer.close()
|
|
109
|
+
await self._writer.wait_closed()
|
|
110
|
+
self._writer = None
|
|
111
|
+
self._reader = None
|
|
112
|
+
self._log("tcp socket closed")
|
|
113
|
+
|
|
114
|
+
async def _read_loop(self, reader: asyncio.StreamReader) -> None:
|
|
115
|
+
decoder = json.JSONDecoder()
|
|
116
|
+
buffer = ""
|
|
112
117
|
while not self._stopping:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
chunk = await reader.read(4096)
|
|
119
|
+
if not chunk:
|
|
120
|
+
raise ConnectionAbortedError("Socket closed by peer")
|
|
121
|
+
buffer += chunk.decode("utf-8", errors="ignore")
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
lstripped = buffer.lstrip()
|
|
125
|
+
if not lstripped:
|
|
126
|
+
buffer = ""
|
|
127
|
+
break
|
|
128
|
+
if lstripped is not buffer:
|
|
129
|
+
buffer = lstripped
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
decoded, end_idx = decoder.raw_decode(buffer)
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
raw = buffer[:end_idx]
|
|
137
|
+
buffer = buffer[end_idx:]
|
|
138
|
+
|
|
139
|
+
message = _parse_message_obj(
|
|
140
|
+
decoded, raw=raw, include_raw=self.include_raw
|
|
141
|
+
)
|
|
142
|
+
if message is None:
|
|
143
|
+
self._log("ignored invalid message (json/envelope)")
|
|
144
|
+
continue
|
|
118
145
|
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
await self._queue.put(message)
|
|
147
|
+
await self._dispatch(message)
|
|
121
148
|
|
|
122
149
|
async def _dispatch(self, message: EventMessage) -> None:
|
|
123
150
|
handlers = self._handlers_any + self._handlers_by_event.get(message.event, [])
|
|
@@ -137,16 +164,21 @@ class StatsClient:
|
|
|
137
164
|
self._logger.info("[StatsClient] %s", message)
|
|
138
165
|
|
|
139
166
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
167
|
+
def _parse_message_obj(
|
|
168
|
+
decoded: Any, *, raw: str, include_raw: bool
|
|
169
|
+
) -> EventMessage | None:
|
|
170
|
+
if not isinstance(decoded, dict):
|
|
144
171
|
return None
|
|
145
|
-
|
|
146
172
|
event = decoded.get("Event")
|
|
147
173
|
data = decoded.get("Data")
|
|
148
174
|
|
|
175
|
+
if isinstance(data, str):
|
|
176
|
+
try:
|
|
177
|
+
data = json.loads(data)
|
|
178
|
+
except json.JSONDecodeError:
|
|
179
|
+
return None
|
|
180
|
+
|
|
149
181
|
if not isinstance(event, str) or not isinstance(data, dict):
|
|
150
182
|
return None
|
|
151
183
|
|
|
152
|
-
return EventMessage(event=event, data=data, raw=raw if include_raw else None)
|
|
184
|
+
return EventMessage(event=event, data=data, raw=raw if include_raw else None)
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Generic, Literal, Mapping, TypeAlias, TypeVar, TypedDict, cast, overload
|
|
5
|
+
|
|
6
|
+
EventName: TypeAlias = Literal[
|
|
7
|
+
"UpdateState",
|
|
8
|
+
"BallHit",
|
|
9
|
+
"ClockUpdatedSeconds",
|
|
10
|
+
"CountdownBegin",
|
|
11
|
+
"CrossbarHit",
|
|
12
|
+
"GoalReplayEnd",
|
|
13
|
+
"GoalReplayStart",
|
|
14
|
+
"GoalReplayWillEnd",
|
|
15
|
+
"GoalScored",
|
|
16
|
+
"MatchCreated",
|
|
17
|
+
"MatchInitialized",
|
|
18
|
+
"MatchDestroyed",
|
|
19
|
+
"MatchEnded",
|
|
20
|
+
"MatchPaused",
|
|
21
|
+
"MatchUnpaused",
|
|
22
|
+
"PodiumStart",
|
|
23
|
+
"ReplayCreated",
|
|
24
|
+
"RoundStarted",
|
|
25
|
+
"StatfeedEvent",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Vector3(TypedDict):
|
|
30
|
+
X: float
|
|
31
|
+
Y: float
|
|
32
|
+
Z: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlayerRef(TypedDict):
|
|
36
|
+
Name: str
|
|
37
|
+
Shortcut: int
|
|
38
|
+
TeamNum: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TeamState(TypedDict):
|
|
42
|
+
Name: str
|
|
43
|
+
TeamNum: int
|
|
44
|
+
Score: int
|
|
45
|
+
ColorPrimary: str
|
|
46
|
+
ColorSecondary: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BallState(TypedDict):
|
|
50
|
+
Speed: float
|
|
51
|
+
TeamNum: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PlayerAttacker(TypedDict):
|
|
55
|
+
Name: str
|
|
56
|
+
Shortcut: int
|
|
57
|
+
TeamNum: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TargetRef(TypedDict):
|
|
61
|
+
Name: str
|
|
62
|
+
Shortcut: int
|
|
63
|
+
TeamNum: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UpdateStatePlayer(TypedDict, total=False):
|
|
67
|
+
Name: str
|
|
68
|
+
PrimaryId: str
|
|
69
|
+
Shortcut: int
|
|
70
|
+
TeamNum: int
|
|
71
|
+
Score: int
|
|
72
|
+
Goals: int
|
|
73
|
+
Shots: int
|
|
74
|
+
Assists: int
|
|
75
|
+
Saves: int
|
|
76
|
+
Touches: int
|
|
77
|
+
CarTouches: int
|
|
78
|
+
Demos: int
|
|
79
|
+
bHasCar: bool
|
|
80
|
+
Speed: float
|
|
81
|
+
Boost: int
|
|
82
|
+
bBoosting: bool
|
|
83
|
+
bOnGround: bool
|
|
84
|
+
bOnWall: bool
|
|
85
|
+
bPowersliding: bool
|
|
86
|
+
bDemolished: bool
|
|
87
|
+
Attacker: PlayerAttacker
|
|
88
|
+
bSupersonic: bool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class UpdateStateGame(TypedDict, total=False):
|
|
92
|
+
Teams: list[TeamState]
|
|
93
|
+
TimeSeconds: int
|
|
94
|
+
bOvertime: bool
|
|
95
|
+
Frame: int
|
|
96
|
+
Elapsed: float
|
|
97
|
+
Ball: BallState
|
|
98
|
+
bReplay: bool
|
|
99
|
+
bHasWinner: bool
|
|
100
|
+
Winner: str
|
|
101
|
+
Arena: str
|
|
102
|
+
bHasTarget: bool
|
|
103
|
+
Target: TargetRef
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class UpdateStatePayload(TypedDict, total=False):
|
|
107
|
+
MatchGuid: str
|
|
108
|
+
Players: list[UpdateStatePlayer]
|
|
109
|
+
Game: UpdateStateGame
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BallHitBall(TypedDict):
|
|
113
|
+
PreHitSpeed: float
|
|
114
|
+
PostHitSpeed: float
|
|
115
|
+
Location: Vector3
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BallHitPayload(TypedDict, total=False):
|
|
119
|
+
MatchGuid: str
|
|
120
|
+
Players: list[PlayerRef]
|
|
121
|
+
Ball: BallHitBall
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ClockUpdatedSecondsPayload(TypedDict, total=False):
|
|
125
|
+
MatchGuid: str
|
|
126
|
+
TimeSeconds: int
|
|
127
|
+
bOvertime: bool
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CountdownBeginPayload(TypedDict, total=False):
|
|
131
|
+
MatchGuid: str
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BallLastTouch(TypedDict):
|
|
135
|
+
Player: PlayerRef
|
|
136
|
+
Speed: float
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CrossbarHitPayload(TypedDict, total=False):
|
|
140
|
+
MatchGuid: str
|
|
141
|
+
BallLocation: Vector3
|
|
142
|
+
BallSpeed: float
|
|
143
|
+
ImpactForce: float
|
|
144
|
+
BallLastTouch: BallLastTouch
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class GoalReplayEndPayload(TypedDict, total=False):
|
|
148
|
+
MatchGuid: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class GoalReplayStartPayload(TypedDict, total=False):
|
|
152
|
+
MatchGuid: str
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class GoalReplayWillEndPayload(TypedDict, total=False):
|
|
156
|
+
MatchGuid: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class GoalScoredPayload(TypedDict, total=False):
|
|
160
|
+
MatchGuid: str
|
|
161
|
+
GoalSpeed: float
|
|
162
|
+
GoalTime: float
|
|
163
|
+
ImpactLocation: Vector3
|
|
164
|
+
Scorer: PlayerRef
|
|
165
|
+
Assister: PlayerRef
|
|
166
|
+
BallLastTouch: BallLastTouch
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class MatchCreatedPayload(TypedDict, total=False):
|
|
170
|
+
MatchGuid: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class MatchInitializedPayload(TypedDict, total=False):
|
|
174
|
+
MatchGuid: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MatchDestroyedPayload(TypedDict, total=False):
|
|
178
|
+
MatchGuid: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class MatchEndedPayload(TypedDict, total=False):
|
|
182
|
+
MatchGuid: str
|
|
183
|
+
WinnerTeamNum: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class MatchPausedPayload(TypedDict, total=False):
|
|
187
|
+
MatchGuid: str
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class MatchUnpausedPayload(TypedDict, total=False):
|
|
191
|
+
MatchGuid: str
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PodiumStartPayload(TypedDict, total=False):
|
|
195
|
+
MatchGuid: str
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ReplayCreatedPayload(TypedDict, total=False):
|
|
199
|
+
MatchGuid: str
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class RoundStartedPayload(TypedDict, total=False):
|
|
203
|
+
MatchGuid: str
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class StatfeedEventPayload(TypedDict, total=False):
|
|
207
|
+
MatchGuid: str
|
|
208
|
+
EventName: str
|
|
209
|
+
Type: str
|
|
210
|
+
MainTarget: PlayerRef
|
|
211
|
+
SecondaryTarget: PlayerRef
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
KnownEventPayload: TypeAlias = (
|
|
215
|
+
UpdateStatePayload
|
|
216
|
+
| BallHitPayload
|
|
217
|
+
| ClockUpdatedSecondsPayload
|
|
218
|
+
| CountdownBeginPayload
|
|
219
|
+
| CrossbarHitPayload
|
|
220
|
+
| GoalReplayEndPayload
|
|
221
|
+
| GoalReplayStartPayload
|
|
222
|
+
| GoalReplayWillEndPayload
|
|
223
|
+
| GoalScoredPayload
|
|
224
|
+
| MatchCreatedPayload
|
|
225
|
+
| MatchInitializedPayload
|
|
226
|
+
| MatchDestroyedPayload
|
|
227
|
+
| MatchEndedPayload
|
|
228
|
+
| MatchPausedPayload
|
|
229
|
+
| MatchUnpausedPayload
|
|
230
|
+
| PodiumStartPayload
|
|
231
|
+
| ReplayCreatedPayload
|
|
232
|
+
| RoundStartedPayload
|
|
233
|
+
| StatfeedEventPayload
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
KnownEventPayloadMap: TypeAlias = dict[EventName, KnownEventPayload]
|
|
237
|
+
|
|
238
|
+
TData = TypeVar("TData", bound=Mapping[str, Any])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(slots=True)
|
|
242
|
+
class TypedEventMessage(Generic[TData]):
|
|
243
|
+
event: str
|
|
244
|
+
data: TData
|
|
245
|
+
raw: str | None = None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
KnownEventMessage: TypeAlias = TypedEventMessage[KnownEventPayload]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@overload
|
|
252
|
+
def cast_event_data(
|
|
253
|
+
event_name: Literal["UpdateState"], data: Mapping[str, Any]
|
|
254
|
+
) -> UpdateStatePayload: ...
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@overload
|
|
258
|
+
def cast_event_data(
|
|
259
|
+
event_name: Literal["BallHit"], data: Mapping[str, Any]
|
|
260
|
+
) -> BallHitPayload: ...
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@overload
|
|
264
|
+
def cast_event_data(
|
|
265
|
+
event_name: Literal["ClockUpdatedSeconds"], data: Mapping[str, Any]
|
|
266
|
+
) -> ClockUpdatedSecondsPayload: ...
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@overload
|
|
270
|
+
def cast_event_data(
|
|
271
|
+
event_name: Literal["CountdownBegin"], data: Mapping[str, Any]
|
|
272
|
+
) -> CountdownBeginPayload: ...
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@overload
|
|
276
|
+
def cast_event_data(
|
|
277
|
+
event_name: Literal["CrossbarHit"], data: Mapping[str, Any]
|
|
278
|
+
) -> CrossbarHitPayload: ...
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@overload
|
|
282
|
+
def cast_event_data(
|
|
283
|
+
event_name: Literal["GoalReplayEnd"], data: Mapping[str, Any]
|
|
284
|
+
) -> GoalReplayEndPayload: ...
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@overload
|
|
288
|
+
def cast_event_data(
|
|
289
|
+
event_name: Literal["GoalReplayStart"], data: Mapping[str, Any]
|
|
290
|
+
) -> GoalReplayStartPayload: ...
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@overload
|
|
294
|
+
def cast_event_data(
|
|
295
|
+
event_name: Literal["GoalReplayWillEnd"], data: Mapping[str, Any]
|
|
296
|
+
) -> GoalReplayWillEndPayload: ...
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@overload
|
|
300
|
+
def cast_event_data(
|
|
301
|
+
event_name: Literal["GoalScored"], data: Mapping[str, Any]
|
|
302
|
+
) -> GoalScoredPayload: ...
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@overload
|
|
306
|
+
def cast_event_data(
|
|
307
|
+
event_name: Literal["MatchCreated"], data: Mapping[str, Any]
|
|
308
|
+
) -> MatchCreatedPayload: ...
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@overload
|
|
312
|
+
def cast_event_data(
|
|
313
|
+
event_name: Literal["MatchInitialized"], data: Mapping[str, Any]
|
|
314
|
+
) -> MatchInitializedPayload: ...
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@overload
|
|
318
|
+
def cast_event_data(
|
|
319
|
+
event_name: Literal["MatchDestroyed"], data: Mapping[str, Any]
|
|
320
|
+
) -> MatchDestroyedPayload: ...
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@overload
|
|
324
|
+
def cast_event_data(
|
|
325
|
+
event_name: Literal["MatchEnded"], data: Mapping[str, Any]
|
|
326
|
+
) -> MatchEndedPayload: ...
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@overload
|
|
330
|
+
def cast_event_data(
|
|
331
|
+
event_name: Literal["MatchPaused"], data: Mapping[str, Any]
|
|
332
|
+
) -> MatchPausedPayload: ...
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@overload
|
|
336
|
+
def cast_event_data(
|
|
337
|
+
event_name: Literal["MatchUnpaused"], data: Mapping[str, Any]
|
|
338
|
+
) -> MatchUnpausedPayload: ...
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@overload
|
|
342
|
+
def cast_event_data(
|
|
343
|
+
event_name: Literal["PodiumStart"], data: Mapping[str, Any]
|
|
344
|
+
) -> PodiumStartPayload: ...
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@overload
|
|
348
|
+
def cast_event_data(
|
|
349
|
+
event_name: Literal["ReplayCreated"], data: Mapping[str, Any]
|
|
350
|
+
) -> ReplayCreatedPayload: ...
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@overload
|
|
354
|
+
def cast_event_data(
|
|
355
|
+
event_name: Literal["RoundStarted"], data: Mapping[str, Any]
|
|
356
|
+
) -> RoundStartedPayload: ...
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@overload
|
|
360
|
+
def cast_event_data(
|
|
361
|
+
event_name: Literal["StatfeedEvent"], data: Mapping[str, Any]
|
|
362
|
+
) -> StatfeedEventPayload: ...
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@overload
|
|
366
|
+
def cast_event_data(event_name: str, data: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cast_event_data(event_name: str, data: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
370
|
+
"""Type-only helper to narrow `data` based on event name."""
|
|
371
|
+
|
|
372
|
+
_ = event_name
|
|
373
|
+
return cast(Mapping[str, Any], data)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rlstatsapi
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Rocket League Stats API client
|
|
5
|
+
Author: Manuel Cabral
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/manucabral/RocketLeagueStatsAPI
|
|
8
|
+
Keywords: rocket-league,stats,socket,asyncio
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Games/Entertainment
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# RL Stats API
|
|
21
|
+
|
|
22
|
+
`rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
From PyPI:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install rlstatsapi
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
From GitHub (latest `main`):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install git+https://github.com/manucabral/RocketLeagueStatsAPI.git
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Rocket League setup
|
|
39
|
+
|
|
40
|
+
Before launching Rocket League, edit:
|
|
41
|
+
|
|
42
|
+
`<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
|
|
43
|
+
|
|
44
|
+
Use at least:
|
|
45
|
+
|
|
46
|
+
- `PacketSendRate=30` (any value `> 0` enables the exporter)
|
|
47
|
+
- `Port=49123`
|
|
48
|
+
|
|
49
|
+
Restart the game after changing the file.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
import logging
|
|
56
|
+
from rlstatsapi import StatsClient
|
|
57
|
+
|
|
58
|
+
logging.basicConfig(level=logging.INFO)
|
|
59
|
+
|
|
60
|
+
async def main() -> None:
|
|
61
|
+
client = StatsClient(log_enabled=True)
|
|
62
|
+
client.on_any(lambda msg: print(msg.event, msg.data))
|
|
63
|
+
|
|
64
|
+
await client.connect()
|
|
65
|
+
try:
|
|
66
|
+
await asyncio.Event().wait()
|
|
67
|
+
finally:
|
|
68
|
+
await client.disconnect()
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Example Event (Goals)
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
from rlstatsapi import StatsClient
|
|
78
|
+
from rlstatsapi.models import EventMessage
|
|
79
|
+
from rlstatsapi.types import GoalScoredPayload, cast_event_data
|
|
80
|
+
|
|
81
|
+
async def on_goal(msg: EventMessage) -> None:
|
|
82
|
+
data: GoalScoredPayload = cast_event_data("GoalScored", msg.data)
|
|
83
|
+
scorer = data.get("Scorer", {})
|
|
84
|
+
print("Goal by:", scorer.get("Name"))
|
|
85
|
+
|
|
86
|
+
async def main() -> None:
|
|
87
|
+
client = StatsClient()
|
|
88
|
+
client.on("GoalScored", on_goal)
|
|
89
|
+
await client.connect()
|
|
90
|
+
try:
|
|
91
|
+
await asyncio.Event().wait()
|
|
92
|
+
finally:
|
|
93
|
+
await client.disconnect()
|
|
94
|
+
|
|
95
|
+
asyncio.run(main())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
## Public API
|
|
100
|
+
|
|
101
|
+
- `StatsClient(...)`
|
|
102
|
+
- `connect()` / `disconnect()`
|
|
103
|
+
- `on(event_name, handler)`
|
|
104
|
+
- `on_any(handler)`
|
|
105
|
+
- `events()`
|
|
106
|
+
- typed helpers: `EventName`, `TypedEventMessage[...]`, `cast_event_data(...)`
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
## Notes
|
|
110
|
+
|
|
111
|
+
- Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
|
|
112
|
+
- In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
|
|
@@ -2,10 +2,10 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/rlstatsapi/__init__.py
|
|
5
|
-
src/rlstatsapi/_websocket.py
|
|
6
5
|
src/rlstatsapi/client.py
|
|
7
6
|
src/rlstatsapi/events.py
|
|
8
7
|
src/rlstatsapi/models.py
|
|
8
|
+
src/rlstatsapi/types.py
|
|
9
9
|
src/rlstatsapi.egg-info/PKG-INFO
|
|
10
10
|
src/rlstatsapi.egg-info/SOURCES.txt
|
|
11
11
|
src/rlstatsapi.egg-info/dependency_links.txt
|
rlstatsapi-0.1.0/PKG-INFO
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: rlstatsapi
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Rocket League Stats API client
|
|
5
|
-
Author: Manuel Cabral
|
|
6
|
-
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/manucabral/RocketLeagueStatsAPI
|
|
8
|
-
Keywords: rocket-league,stats,websocket,asyncio
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Operating System :: OS Independent
|
|
13
|
-
Classifier: Topic :: Games/Entertainment
|
|
14
|
-
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
-
Requires-Python: >=3.11
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Dynamic: license-file
|
|
19
|
-
|
|
20
|
-
# rlstatsapi
|
|
21
|
-
|
|
22
|
-
Simple and fast Python client to read Rocket League Stats API events.
|
|
23
|
-
|
|
24
|
-
## Install
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
pip install -e .
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Configure Rocket League
|
|
31
|
-
|
|
32
|
-
Before starting the game, edit:
|
|
33
|
-
|
|
34
|
-
`<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
|
|
35
|
-
|
|
36
|
-
Minimum required values:
|
|
37
|
-
|
|
38
|
-
- `PacketSendRate=60` (any value > 0)
|
|
39
|
-
- `Port=49123`
|
|
40
|
-
|
|
41
|
-
Then restart Rocket League.
|
|
42
|
-
|
|
43
|
-
## Quick usage
|
|
44
|
-
|
|
45
|
-
```python
|
|
46
|
-
import asyncio
|
|
47
|
-
import logging
|
|
48
|
-
from rlstatsapi import StatsClient
|
|
49
|
-
|
|
50
|
-
logging.basicConfig(level=logging.INFO)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
async def main() -> None:
|
|
54
|
-
client = StatsClient(log_enabled=True)
|
|
55
|
-
client.on_any(lambda msg: print(msg.event, msg.data))
|
|
56
|
-
|
|
57
|
-
await client.connect()
|
|
58
|
-
try:
|
|
59
|
-
await asyncio.Event().wait()
|
|
60
|
-
finally:
|
|
61
|
-
await client.disconnect()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
asyncio.run(main())
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Public API
|
|
68
|
-
|
|
69
|
-
- `StatsClient(...)`
|
|
70
|
-
- `connect()`
|
|
71
|
-
- `disconnect()`
|
|
72
|
-
- `on(event_name, handler)`
|
|
73
|
-
- `on_any(handler)`
|
|
74
|
-
- `events()`
|
rlstatsapi-0.1.0/README.md
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# rlstatsapi
|
|
2
|
-
|
|
3
|
-
Simple and fast Python client to read Rocket League Stats API events.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
pip install -e .
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Configure Rocket League
|
|
12
|
-
|
|
13
|
-
Before starting the game, edit:
|
|
14
|
-
|
|
15
|
-
`<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
|
|
16
|
-
|
|
17
|
-
Minimum required values:
|
|
18
|
-
|
|
19
|
-
- `PacketSendRate=60` (any value > 0)
|
|
20
|
-
- `Port=49123`
|
|
21
|
-
|
|
22
|
-
Then restart Rocket League.
|
|
23
|
-
|
|
24
|
-
## Quick usage
|
|
25
|
-
|
|
26
|
-
```python
|
|
27
|
-
import asyncio
|
|
28
|
-
import logging
|
|
29
|
-
from rlstatsapi import StatsClient
|
|
30
|
-
|
|
31
|
-
logging.basicConfig(level=logging.INFO)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
async def main() -> None:
|
|
35
|
-
client = StatsClient(log_enabled=True)
|
|
36
|
-
client.on_any(lambda msg: print(msg.event, msg.data))
|
|
37
|
-
|
|
38
|
-
await client.connect()
|
|
39
|
-
try:
|
|
40
|
-
await asyncio.Event().wait()
|
|
41
|
-
finally:
|
|
42
|
-
await client.disconnect()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
asyncio.run(main())
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
## Public API
|
|
49
|
-
|
|
50
|
-
- `StatsClient(...)`
|
|
51
|
-
- `connect()`
|
|
52
|
-
- `disconnect()`
|
|
53
|
-
- `on(event_name, handler)`
|
|
54
|
-
- `on_any(handler)`
|
|
55
|
-
- `events()`
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import base64
|
|
5
|
-
import hashlib
|
|
6
|
-
import os
|
|
7
|
-
import ssl
|
|
8
|
-
from dataclasses import dataclass
|
|
9
|
-
|
|
10
|
-
_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class WebSocketHandshakeError(ConnectionError):
|
|
14
|
-
"""Raised when websocket upgrade fails."""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass(slots=True)
|
|
18
|
-
class SimpleWebSocket:
|
|
19
|
-
"""Minimal stdlib websocket client optimized for receiving text frames."""
|
|
20
|
-
|
|
21
|
-
reader: asyncio.StreamReader
|
|
22
|
-
writer: asyncio.StreamWriter
|
|
23
|
-
|
|
24
|
-
async def recv_text(self) -> str:
|
|
25
|
-
opcode, payload = await self._read_frame()
|
|
26
|
-
|
|
27
|
-
if opcode == 0x8: # close
|
|
28
|
-
await self.close()
|
|
29
|
-
raise ConnectionAbortedError("Websocket closed by peer")
|
|
30
|
-
if opcode == 0x9: # ping
|
|
31
|
-
await self._send_pong(payload)
|
|
32
|
-
return await self.recv_text()
|
|
33
|
-
if opcode == 0xA: # pong
|
|
34
|
-
return await self.recv_text()
|
|
35
|
-
if opcode != 0x1:
|
|
36
|
-
raise ConnectionError(f"Unsupported opcode: {opcode}")
|
|
37
|
-
|
|
38
|
-
return payload.decode("utf-8")
|
|
39
|
-
|
|
40
|
-
async def close(self) -> None:
|
|
41
|
-
if self.writer.is_closing():
|
|
42
|
-
return
|
|
43
|
-
try:
|
|
44
|
-
await self._send_frame(0x8, b"")
|
|
45
|
-
except Exception:
|
|
46
|
-
pass
|
|
47
|
-
self.writer.close()
|
|
48
|
-
await self.writer.wait_closed()
|
|
49
|
-
|
|
50
|
-
async def _send_pong(self, payload: bytes) -> None:
|
|
51
|
-
await self._send_frame(0xA, payload)
|
|
52
|
-
|
|
53
|
-
async def _send_frame(self, opcode: int, payload: bytes) -> None:
|
|
54
|
-
fin_and_opcode = 0x80 | (opcode & 0x0F)
|
|
55
|
-
mask_and_len = 0x80
|
|
56
|
-
payload_len = len(payload)
|
|
57
|
-
|
|
58
|
-
header = bytearray([fin_and_opcode])
|
|
59
|
-
if payload_len < 126:
|
|
60
|
-
header.append(mask_and_len | payload_len)
|
|
61
|
-
elif payload_len <= 0xFFFF:
|
|
62
|
-
header.append(mask_and_len | 126)
|
|
63
|
-
header.extend(payload_len.to_bytes(2, "big"))
|
|
64
|
-
else:
|
|
65
|
-
header.append(mask_and_len | 127)
|
|
66
|
-
header.extend(payload_len.to_bytes(8, "big"))
|
|
67
|
-
|
|
68
|
-
mask = os.urandom(4)
|
|
69
|
-
header.extend(mask)
|
|
70
|
-
|
|
71
|
-
masked_payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
72
|
-
|
|
73
|
-
self.writer.write(header)
|
|
74
|
-
self.writer.write(masked_payload)
|
|
75
|
-
await self.writer.drain()
|
|
76
|
-
|
|
77
|
-
async def _read_frame(self) -> tuple[int, bytes]:
|
|
78
|
-
first_two = await self.reader.readexactly(2)
|
|
79
|
-
first, second = first_two[0], first_two[1]
|
|
80
|
-
|
|
81
|
-
fin = (first & 0x80) != 0
|
|
82
|
-
opcode = first & 0x0F
|
|
83
|
-
masked = (second & 0x80) != 0
|
|
84
|
-
payload_len = second & 0x7F
|
|
85
|
-
|
|
86
|
-
if payload_len == 126:
|
|
87
|
-
payload_len = int.from_bytes(await self.reader.readexactly(2), "big")
|
|
88
|
-
elif payload_len == 127:
|
|
89
|
-
payload_len = int.from_bytes(await self.reader.readexactly(8), "big")
|
|
90
|
-
|
|
91
|
-
mask_key = await self.reader.readexactly(4) if masked else b""
|
|
92
|
-
payload = await self.reader.readexactly(payload_len)
|
|
93
|
-
|
|
94
|
-
if masked:
|
|
95
|
-
payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
|
|
96
|
-
|
|
97
|
-
if not fin:
|
|
98
|
-
raise ConnectionError("Fragmented frames are not supported")
|
|
99
|
-
|
|
100
|
-
return opcode, payload
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
async def connect_websocket(
|
|
104
|
-
*,
|
|
105
|
-
host: str,
|
|
106
|
-
port: int,
|
|
107
|
-
path: str = "/",
|
|
108
|
-
timeout: float = 5.0,
|
|
109
|
-
secure: bool = False,
|
|
110
|
-
) -> SimpleWebSocket:
|
|
111
|
-
"""Open websocket connection via stdlib stream upgrade."""
|
|
112
|
-
|
|
113
|
-
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
114
|
-
|
|
115
|
-
ssl_ctx = ssl.create_default_context() if secure else None
|
|
116
|
-
reader, writer = await asyncio.wait_for(
|
|
117
|
-
asyncio.open_connection(host=host, port=port, ssl=ssl_ctx),
|
|
118
|
-
timeout=timeout,
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
request = (
|
|
122
|
-
f"GET {path} HTTP/1.1\r\n"
|
|
123
|
-
f"Host: {host}:{port}\r\n"
|
|
124
|
-
"Upgrade: websocket\r\n"
|
|
125
|
-
"Connection: Upgrade\r\n"
|
|
126
|
-
f"Sec-WebSocket-Key: {key}\r\n"
|
|
127
|
-
"Sec-WebSocket-Version: 13\r\n"
|
|
128
|
-
"\r\n"
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
writer.write(request.encode("ascii"))
|
|
132
|
-
await writer.drain()
|
|
133
|
-
|
|
134
|
-
response = await _read_http_response(reader)
|
|
135
|
-
status_line = response[0]
|
|
136
|
-
|
|
137
|
-
if " 101 " not in status_line:
|
|
138
|
-
writer.close()
|
|
139
|
-
await writer.wait_closed()
|
|
140
|
-
raise WebSocketHandshakeError(f"Handshake failed: {status_line.strip()}")
|
|
141
|
-
|
|
142
|
-
headers = _parse_headers(response[1:])
|
|
143
|
-
accept = headers.get("sec-websocket-accept")
|
|
144
|
-
|
|
145
|
-
expected = base64.b64encode(
|
|
146
|
-
hashlib.sha1((key + _WS_GUID).encode("ascii")).digest()
|
|
147
|
-
).decode("ascii")
|
|
148
|
-
if accept != expected:
|
|
149
|
-
writer.close()
|
|
150
|
-
await writer.wait_closed()
|
|
151
|
-
raise WebSocketHandshakeError("Invalid Sec-WebSocket-Accept from server")
|
|
152
|
-
|
|
153
|
-
return SimpleWebSocket(reader=reader, writer=writer)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
async def _read_http_response(reader: asyncio.StreamReader) -> list[str]:
|
|
157
|
-
lines: list[str] = []
|
|
158
|
-
while True:
|
|
159
|
-
line = (await reader.readline()).decode("iso-8859-1")
|
|
160
|
-
if not line:
|
|
161
|
-
raise WebSocketHandshakeError("Unexpected EOF during handshake")
|
|
162
|
-
lines.append(line)
|
|
163
|
-
if line == "\r\n":
|
|
164
|
-
return lines
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def _parse_headers(lines: list[str]) -> dict[str, str]:
|
|
168
|
-
headers: dict[str, str] = {}
|
|
169
|
-
for line in lines:
|
|
170
|
-
if line == "\r\n":
|
|
171
|
-
break
|
|
172
|
-
if ":" not in line:
|
|
173
|
-
continue
|
|
174
|
-
key, value = line.split(":", 1)
|
|
175
|
-
headers[key.strip().lower()] = value.strip()
|
|
176
|
-
return headers
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: rlstatsapi
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Rocket League Stats API client
|
|
5
|
-
Author: Manuel Cabral
|
|
6
|
-
License: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/manucabral/RocketLeagueStatsAPI
|
|
8
|
-
Keywords: rocket-league,stats,websocket,asyncio
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Operating System :: OS Independent
|
|
13
|
-
Classifier: Topic :: Games/Entertainment
|
|
14
|
-
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
-
Requires-Python: >=3.11
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Dynamic: license-file
|
|
19
|
-
|
|
20
|
-
# rlstatsapi
|
|
21
|
-
|
|
22
|
-
Simple and fast Python client to read Rocket League Stats API events.
|
|
23
|
-
|
|
24
|
-
## Install
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
pip install -e .
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Configure Rocket League
|
|
31
|
-
|
|
32
|
-
Before starting the game, edit:
|
|
33
|
-
|
|
34
|
-
`<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
|
|
35
|
-
|
|
36
|
-
Minimum required values:
|
|
37
|
-
|
|
38
|
-
- `PacketSendRate=60` (any value > 0)
|
|
39
|
-
- `Port=49123`
|
|
40
|
-
|
|
41
|
-
Then restart Rocket League.
|
|
42
|
-
|
|
43
|
-
## Quick usage
|
|
44
|
-
|
|
45
|
-
```python
|
|
46
|
-
import asyncio
|
|
47
|
-
import logging
|
|
48
|
-
from rlstatsapi import StatsClient
|
|
49
|
-
|
|
50
|
-
logging.basicConfig(level=logging.INFO)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
async def main() -> None:
|
|
54
|
-
client = StatsClient(log_enabled=True)
|
|
55
|
-
client.on_any(lambda msg: print(msg.event, msg.data))
|
|
56
|
-
|
|
57
|
-
await client.connect()
|
|
58
|
-
try:
|
|
59
|
-
await asyncio.Event().wait()
|
|
60
|
-
finally:
|
|
61
|
-
await client.disconnect()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
asyncio.run(main())
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Public API
|
|
68
|
-
|
|
69
|
-
- `StatsClient(...)`
|
|
70
|
-
- `connect()`
|
|
71
|
-
- `disconnect()`
|
|
72
|
-
- `on(event_name, handler)`
|
|
73
|
-
- `on_any(handler)`
|
|
74
|
-
- `events()`
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|