aiosatisfactory 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,21 @@
1
+ import aiohttp
2
+ from .lightweight import LightweightAPI
3
+ from .https import HttpsAPI
4
+
5
+
6
+ class SatisfactoryServer:
7
+ def __init__(self, host: str, port: int = 7777, self_signed_certificate: bool = True, session: aiohttp.ClientSession | None = None, api_token: str = ""):
8
+ self._lightweight = LightweightAPI(host, port)
9
+ if session is None:
10
+ self._https = None
11
+ else:
12
+ self._https = HttpsAPI(self_signed_certificate, host, port, session, api_token)
13
+
14
+ @property
15
+ def lightweight(self) -> LightweightAPI:
16
+ return self._lightweight
17
+
18
+ @property
19
+ def https(self) -> HttpsAPI | None:
20
+ return self._https
21
+
@@ -0,0 +1,36 @@
1
+ import ssl
2
+ import aiohttp
3
+ from .api import ApiEndpoints
4
+
5
+ class HttpsAPI:
6
+
7
+ def __init__(self, self_signed_certificate: bool, host: str, port: int, session: aiohttp.ClientSession, api_token: str):
8
+ self._host = host
9
+ self._port = port
10
+ self._api_token = api_token
11
+ self._headers = {
12
+ 'content-type': 'application/json',
13
+ }
14
+
15
+ self._ssl_context = ssl.create_default_context()
16
+ if self_signed_certificate:
17
+ self._ssl_context.check_hostname = False
18
+ self._ssl_context.verify_mode = ssl.CERT_NONE
19
+
20
+ if api_token:
21
+ self._headers["Authorization"] = f"Bearer {self._api_token}"
22
+
23
+ self._url = f"https://{self._host}:{self._port}/api/v1"
24
+
25
+ self._api = ApiEndpoints(session, self._url, self._headers, self._ssl_context)
26
+
27
+ @property
28
+ def api_token(self, new_token: str = "") -> str:
29
+ if new_token:
30
+ self._api_token = new_token
31
+ self._headers["Authorization"] = f"Bearer {self._api_token}"
32
+ return self._api_token
33
+
34
+ @property
35
+ def api(self) -> ApiEndpoints:
36
+ return self._api
@@ -0,0 +1,375 @@
1
+ import json, aiohttp
2
+ import ssl
3
+ from .models import (
4
+ BaseResponse,
5
+ HealthCheckResponse,
6
+ PasswordlessLoginResponse,
7
+ PasswordLoginResponse,
8
+ QueryServerStateResponse,
9
+ GetServerOptionsResponse,
10
+ GetAdvancedGameSettingsResponse,
11
+ ClaimServerResponse,
12
+ RunCommandResponse,
13
+ ServerNewGameData,
14
+ EnumerateSessionsResponse,
15
+ DownloadSaveGameResponse,
16
+ ErrorResponse
17
+ )
18
+
19
+ class ApiEndpoints():
20
+ def __init__(self, session: aiohttp.ClientSession, url: str, headers: dict[str, str], ssl_context: ssl.SSLContext):
21
+ self._session = session
22
+ self._url = url
23
+ self._headers = headers
24
+ self._ssl_context = ssl_context
25
+
26
+ async def _post(self, function: str, data: dict[str, str] = {}) -> aiohttp.ClientResponse:
27
+
28
+ return await self._session.post(
29
+ self._url,
30
+ json={"function": function, "data": data},
31
+ headers=self._headers,
32
+ ssl=self._ssl_context
33
+ )
34
+
35
+ def _raise_error(self, response: aiohttp.ClientResponse, data: dict[str, str]) -> None:
36
+ if response.status >= 400:
37
+ raise ErrorResponse(data["errorCode"], data.get("errorMessage"), data.get("errorData"))
38
+
39
+
40
+ async def health_check(self, client_custom_data: str = "") -> HealthCheckResponse:
41
+ response = await self._post(
42
+ "HealthCheck",
43
+ {
44
+ "ClientCustomData": client_custom_data
45
+ }
46
+ )
47
+ data = await response.json()
48
+ self._raise_error(response, data)
49
+ data = data["data"]
50
+ return HealthCheckResponse(
51
+ response.status,
52
+ data["health"],
53
+ data["serverCustomData"]
54
+ )
55
+
56
+
57
+
58
+ async def verify_authentication_token(self) -> BaseResponse:
59
+ response = await self._post("VerifyAuthenticationToken")
60
+ if response.status >= 400:
61
+ data = await response.json()
62
+ self._raise_error(response, data)
63
+ return BaseResponse(response.status)
64
+
65
+
66
+ async def passwordless_login(self, minimum_privilege_level: str) -> PasswordlessLoginResponse:
67
+ response = await self._post(
68
+ "PasswordlessLogin",
69
+ {
70
+ "MinimumPrivilegeLevel": minimum_privilege_level
71
+ }
72
+ )
73
+ data = await response.json()
74
+ self._raise_error(response, data)
75
+ data = data["data"]
76
+ return PasswordlessLoginResponse(
77
+ response.status,
78
+ data["authenticationToken"]
79
+ )
80
+
81
+
82
+
83
+ async def passwordlogin(self, minimum_privilege_level: str, password: str) -> PasswordLoginResponse:
84
+ response = await self._post(
85
+ "PasswordLogin",
86
+ {
87
+ "MinimumPrivilegeLevel": minimum_privilege_level,
88
+ "Password": password
89
+ }
90
+ )
91
+ data = await response.json()
92
+ self._raise_error(response, data)
93
+ data = data["data"]
94
+ return PasswordLoginResponse(
95
+ response.status,
96
+ data["authenticationToken"]
97
+ )
98
+
99
+
100
+
101
+ async def query_server_state(self) -> QueryServerStateResponse:
102
+ response = await self._post("QueryServerState")
103
+ data = await response.json()
104
+ self._raise_error(response, data)
105
+ data = data["ServerGameState"]
106
+ return QueryServerStateResponse(
107
+ response.status,
108
+ data["ActiveSessionName"],
109
+ data["NumConnectedPlayers"],
110
+ data["PlayerLimit"],
111
+ data["TechTier"],
112
+ data["ActiveSchematic"],
113
+ data["GamePhase"],
114
+ data["IsGameRunning"],
115
+ data["TotalGameDuration"],
116
+ data["IsGamePaused"],
117
+ data["AverageTickRate"],
118
+ data["AutoLoadSessionName"]
119
+ )
120
+
121
+
122
+
123
+ async def get_server_options(self) -> GetServerOptionsResponse:
124
+ response = await self._post("GetServerOptions")
125
+ data = await response.json()
126
+ self._raise_error(response, data)
127
+ return GetServerOptionsResponse(
128
+ response.status,
129
+ data["ServerOptions"],
130
+ data["PendingServerOptions"]
131
+ )
132
+
133
+
134
+
135
+ async def get_advanced_game_settings(self) -> GetAdvancedGameSettingsResponse:
136
+ response = await self._post("GetAdvancedGameSettings")
137
+ data = await response.json()
138
+ self._raise_error(response, data)
139
+ return GetAdvancedGameSettingsResponse(
140
+ response.status,
141
+ data["CreativeModeEnabled"],
142
+ data["AdvancedGameSettings"]
143
+ )
144
+
145
+
146
+
147
+ async def apply_advanced_game_settings(self, applied_advanced_game_settings: dict[str, str]) -> BaseResponse:
148
+ response = await self._post(
149
+ "ApplyAdvancedGameSettings",
150
+ {
151
+ "AppliedAdvancedGameSettings": json.dumps(applied_advanced_game_settings)
152
+ }
153
+ )
154
+ data = await response.json()
155
+ self._raise_error(response, data)
156
+ return BaseResponse(response.status)
157
+
158
+
159
+
160
+ async def claim_server(self, server_name: str, admin_password: str) -> ClaimServerResponse:
161
+ response = await self._post(
162
+ "ClaimServer",
163
+ {
164
+ "ServerName": server_name,
165
+ "AdminPassword": admin_password
166
+ }
167
+ )
168
+ data = await response.json()
169
+ self._raise_error(response, data)
170
+ return ClaimServerResponse(
171
+ response.status,
172
+ data["authenticationToken"]
173
+ )
174
+
175
+
176
+
177
+ async def rename_server(self, server_name: str) -> BaseResponse:
178
+ response = await self._post(
179
+ "RenameServer",
180
+ {
181
+ "ServerName": server_name
182
+ }
183
+ )
184
+ data = await response.json()
185
+ self._raise_error(response, data)
186
+ return BaseResponse(response.status)
187
+
188
+
189
+
190
+ async def set_client_password(self, password: str) -> BaseResponse:
191
+ response = await self._post(
192
+ "SetClientPassword",
193
+ {
194
+ "Password": password
195
+ }
196
+ )
197
+ data = await response.json()
198
+ self._raise_error(response, data)
199
+ return BaseResponse(response.status)
200
+
201
+
202
+
203
+ async def set_admin_password(self, password: str, authentication_token: str) -> BaseResponse:
204
+ response = await self._post(
205
+ "SetAdminPassword",
206
+ {
207
+ "Password": password,
208
+ "AuthenticationToken": authentication_token #??? - We have to generate a new token? - Maybe the documentation is wrong and this field is returned from the server?
209
+ }
210
+ )
211
+ data = await response.json()
212
+ self._raise_error(response, data)
213
+ return BaseResponse(response.status)
214
+
215
+
216
+
217
+ async def set_auto_load_session_name(self, session_name: str) -> BaseResponse:
218
+ response = await self._post(
219
+ "SetAutoLoadSessionName",
220
+ {
221
+ "SessionName": session_name
222
+ }
223
+ )
224
+ data = await response.json()
225
+ self._raise_error(response, data)
226
+ return BaseResponse(response.status)
227
+
228
+
229
+
230
+ async def run_command(self, command: str) -> RunCommandResponse:
231
+ response = await self._post(
232
+ "RunCommand",
233
+ {
234
+ "Command": command
235
+ }
236
+ )
237
+ data = await response.json()
238
+ self._raise_error(response, data)
239
+ return RunCommandResponse(
240
+ response.status,
241
+ data["CommandResult"],
242
+ data["ReturnValue"]
243
+ )
244
+
245
+
246
+
247
+ async def shutdown(self) -> BaseResponse:
248
+ response = await self._post("Shutdown")
249
+ data = await response.json()
250
+ self._raise_error(response, data)
251
+ return BaseResponse(response.status)
252
+
253
+
254
+
255
+ async def apply_server_options(self, updated_server_options: dict[str, str]) -> BaseResponse:
256
+ response = await self._post(
257
+ "ApplyServerOptions",
258
+ {
259
+ "UpdatedServerOptions": json.dumps(updated_server_options)
260
+ }
261
+ )
262
+ data = await response.json()
263
+ self._raise_error(response, data)
264
+ return BaseResponse(response.status)
265
+
266
+
267
+
268
+ async def create_new_game(self, new_game_data: ServerNewGameData) -> BaseResponse:
269
+ response = await self._post(
270
+ "CreateNewGame",
271
+ {
272
+ "NewGameData": json.dumps(new_game_data)
273
+ }
274
+ )
275
+ data = await response.json()
276
+ self._raise_error(response, data)
277
+ return BaseResponse(response.status)
278
+
279
+
280
+
281
+ async def save_game(self, save_name: str) -> BaseResponse:
282
+ response = await self._post(
283
+ "SaveGame",
284
+ {
285
+ "SaveName": save_name
286
+ }
287
+ )
288
+ data = await response.json()
289
+ self._raise_error(response, data)
290
+ return BaseResponse(response.status)
291
+
292
+
293
+
294
+ async def delete_save_file(self, save_name: str) -> BaseResponse:
295
+ response = await self._post(
296
+ "DeleteSaveFile",
297
+ {
298
+ "SaveName": save_name
299
+ }
300
+ )
301
+ data = await response.json()
302
+ self._raise_error(response, data)
303
+ return BaseResponse(response.status)
304
+
305
+
306
+
307
+ async def delete_save_session(self, session_name: str) -> BaseResponse:
308
+ response = await self._post(
309
+ "DeleteSaveSession",
310
+ {
311
+ "SessionName": session_name
312
+ }
313
+ )
314
+ data = await response.json()
315
+ self._raise_error(response, data)
316
+ return BaseResponse(response.status)
317
+
318
+
319
+
320
+ async def enumerate_sessions(self) -> EnumerateSessionsResponse:
321
+ response = await self._post("EnumerateSessions")
322
+ data = await response.json()
323
+ self._raise_error(response, data)
324
+ return EnumerateSessionsResponse(
325
+ response.status,
326
+ data["Sessions"],
327
+ data["CurrentSessionIndex"]
328
+ )
329
+
330
+
331
+
332
+ async def load_game(self, save_name: str, enable_advanced_game_settings: bool) -> BaseResponse:
333
+ response = await self._post(
334
+ "LoadGame",
335
+ {
336
+ "SaveName": save_name,
337
+ "EnableAdvancedGameSettings": str(enable_advanced_game_settings)
338
+ }
339
+ )
340
+ data = await response.json()
341
+ self._raise_error(response, data)
342
+ return BaseResponse(response.status)
343
+
344
+
345
+
346
+ #TODO
347
+ async def upload_save_game(self, save_name: str, load_save_game: bool, enable_advanced_game_settings: bool) -> BaseResponse:
348
+ response = await self._post(
349
+ "UploadSaveGame",
350
+ {
351
+ "SaveName": save_name,
352
+ "LoadSaveGame": str(load_save_game),
353
+ "EnableAdvancedGameSettings": str(enable_advanced_game_settings)
354
+ }
355
+ )
356
+ data = await response.json()
357
+ self._raise_error(response, data)
358
+ return BaseResponse(response.status)
359
+
360
+
361
+
362
+ #TODO
363
+ async def download_save_game(self, save_name: str) -> DownloadSaveGameResponse:
364
+ response = await self._post(
365
+ "DownloadSaveGame",
366
+ {
367
+ "SaveName": save_name
368
+ }
369
+ )
370
+ data = await response.json()
371
+ self._raise_error(response, data)
372
+ return DownloadSaveGameResponse(
373
+ response.status,
374
+ data["SaveData"]
375
+ )
@@ -0,0 +1,118 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class BaseResponse:
7
+ status_code: int
8
+
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class HealthCheckResponse(BaseResponse):
13
+ health: str
14
+ server_custom_data: str
15
+
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class PasswordlessLoginResponse(BaseResponse):
20
+ authentication_token: str
21
+
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class PasswordLoginResponse(BaseResponse):
26
+ authentication_token: str
27
+
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class QueryServerStateResponse(BaseResponse):
32
+ active_session_name: str
33
+ num_connected_players: int
34
+ player_limit: int
35
+ tech_tier: int
36
+ active_schematic: str
37
+ game_phase: str
38
+ is_game_running: bool
39
+ total_game_duration: int
40
+ is_game_paused: bool
41
+ average_tick_rate: float
42
+ auto_load_session_name: str
43
+
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class GetServerOptionsResponse(BaseResponse):
48
+ server_options: dict[str, str]
49
+ pending_server_options: dict[str, str]
50
+
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class GetAdvancedGameSettingsResponse(BaseResponse):
55
+ creative_mode_enabled: bool
56
+ advanced_game_settings: dict[str, str]
57
+
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ClaimServerResponse(BaseResponse):
62
+ authentication_token: str
63
+
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class RunCommandResponse(BaseResponse):
68
+ command_result: str
69
+ return_value: bool
70
+
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class ServerNewGameData:
75
+ session_name: str
76
+ map_name: str
77
+ starting_location: str
78
+ skip_onboarding: bool
79
+ advanced_game_settings: dict[str, str]
80
+ custom_options_only_for_modding: dict[str, str]
81
+
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class SaveHeader:
86
+ save_version: int
87
+ build_version: int
88
+ save_name: str
89
+ map_name: str
90
+ map_options: str
91
+ session_name: str
92
+ play_duration_seconds: int
93
+ save_date_time: str
94
+ is_modded_save: bool
95
+ is_edited_save: bool
96
+ is_creative_mode_enabled: bool
97
+
98
+ @dataclass(slots=True)
99
+ class SessionSaveStruct:
100
+ session_name: str
101
+ save_headers: list[SaveHeader]
102
+
103
+ @dataclass(slots=True)
104
+ class EnumerateSessionsResponse(BaseResponse):
105
+ sessions: list[SessionSaveStruct]
106
+ current_session_index: int
107
+
108
+ @dataclass(slots=True)
109
+ class DownloadSaveGameResponse(BaseResponse):
110
+ save_data: bytes
111
+
112
+
113
+
114
+ @dataclass(slots=True)
115
+ class ErrorResponse(Exception):
116
+ error_code: str
117
+ error_message: str | None = None
118
+ error_details: str | None = None
@@ -0,0 +1,38 @@
1
+ import struct
2
+ from .udp import udp_query
3
+ from .response import ServerStateResponse
4
+ from .request import ServerStateRequest
5
+
6
+ from .const import (
7
+ POLL_FORMAT,
8
+ PROTOCOL_MAGIC,
9
+ MESSAGE_TYPE_POLL,
10
+ PROTOCOL_VERSION,
11
+ TERMINATOR_BYTE
12
+ )
13
+
14
+ class LightweightAPIResult():
15
+ def __init__(self, request: ServerStateRequest, response: ServerStateResponse):
16
+ self._request = request
17
+ self._response = response
18
+
19
+ @property
20
+ def request(self) -> ServerStateRequest:
21
+ return self._request
22
+
23
+ @property
24
+ def response(self) -> ServerStateResponse:
25
+ return self._response
26
+
27
+ class LightweightAPI():
28
+ def __init__(self, host: str, port: int):
29
+ self._host = host
30
+ self._port = port
31
+
32
+ async def query(self, Cookie: int) -> LightweightAPIResult | None:
33
+
34
+ request = struct.pack(POLL_FORMAT, PROTOCOL_MAGIC, MESSAGE_TYPE_POLL, PROTOCOL_VERSION, Cookie, TERMINATOR_BYTE)
35
+ response = await udp_query(self._host, self._port, request)
36
+ if response is None:
37
+ return None
38
+ return LightweightAPIResult(ServerStateRequest(request), ServerStateResponse(response))
@@ -0,0 +1,75 @@
1
+ """Constants for Satisfactory LightWeight Query protocol."""
2
+
3
+ """
4
+ All the data in this protocol is in little-endian byte order as stated in the official documentation
5
+ The conversion between the documentation DataType and struct formats is as follows:
6
+ - uint8 -> B
7
+ - uint16 -> H
8
+ - uint32 -> L
9
+ - uint64 -> Q
10
+ - uint8[] -> s
11
+ """
12
+
13
+ """General purpose fields and formats."""
14
+
15
+ PROTOCOL_MAGIC: int = 0xF6D5
16
+ PROTOCOL_MAGIC_FORMAT: str = "<H"
17
+ PROTOCOL_MAGIC_OFFSET: int = 0
18
+
19
+ MESSAGE_TYPE_FORMAT: str = "<B"
20
+ MESSAGE_TYPE_OFFSET: int = 2
21
+
22
+ PROTOCOL_VERSION: int = 1
23
+ PROTOCOL_VERSION_FORMAT: str = "<B"
24
+ PROTOCOL_VERSION_OFFSET: int = 3
25
+
26
+ # The payload starts after the 3-byte header, we use this constant to have a direct reference to the documentation offsets
27
+ PAYLOAD_START_BYTE: int = 4
28
+
29
+ # The payload always starts with a cookie to identify the request/response pair
30
+ COOKIE_FORMAT: str = "<Q"
31
+ COOKIE_OFFSET: int = PAYLOAD_START_BYTE + 0
32
+
33
+ # The last byte is always the terminator byte
34
+ TERMINATOR_BYTE: int = 0x01
35
+ TERMINATOR_BYTE_FORMAT: str = "<B"
36
+ TERMINATOR_BYTE_OFFSET: int = -1
37
+
38
+ """Poll specific field formats and offsets."""
39
+
40
+ MESSAGE_TYPE_POLL: int = 0
41
+
42
+ POLL_FORMAT: str = "<HBBQB"
43
+
44
+ SUB_STATES_STRUCTURE_SIZE: int = 3
45
+
46
+ """Response specific field formats and offsets."""
47
+
48
+ MESSAGE_TYPE_RESPONSE: int = 1
49
+
50
+ SERVER_STATE_FORMAT: str = "<B"
51
+ SERVER_STATE_OFFSET: int = PAYLOAD_START_BYTE + 8
52
+
53
+ SERVER_NET_CL_FORMAT: str = "<L"
54
+ SERVER_NET_CL_OFFSET: int = PAYLOAD_START_BYTE + 9
55
+
56
+ SERVER_FLAGS_FORMAT: str = "<Q"
57
+ SERVER_FLAGS_OFFSET: int = PAYLOAD_START_BYTE + 13
58
+
59
+ NUM_SUB_STATES_FORMAT: str = "<B"
60
+ NUM_SUB_STATES_OFFSET: int = PAYLOAD_START_BYTE + 21
61
+
62
+ # The sub-states section directly start with the first sub state ID
63
+ SUB_STATE_ID_FORMAT: str = "<B"
64
+ SUB_STATE_ID_OFFSET: int = PAYLOAD_START_BYTE + 22
65
+
66
+ # The sub-state version follows after it's ID, the documentation has a typo here saying there is an 8 bytes offset
67
+ SUB_STATE_VERSION_FORMAT: str = "<H"
68
+ BASE_SUB_STATE_VERSION_OFFSET: int = PAYLOAD_START_BYTE + 22 + 1
69
+
70
+ SERVER_NAME_LENGTH_FORMAT: str = "<H"
71
+ BASE_SERVER_NAME_LENGTH_OFFSET: int = PAYLOAD_START_BYTE + 22
72
+
73
+ # {} is to be replaced with the length of the server name + 1
74
+ SERVER_NAME_FORMAT: str = "<{}s"
75
+ BASE_SERVER_NAME_OFFSET: int = PAYLOAD_START_BYTE + 22 + 1
@@ -0,0 +1,33 @@
1
+ import struct
2
+
3
+ from .const import (
4
+ PROTOCOL_MAGIC_FORMAT, PROTOCOL_MAGIC_OFFSET,
5
+ MESSAGE_TYPE_FORMAT, MESSAGE_TYPE_OFFSET,
6
+ PROTOCOL_VERSION_FORMAT, PROTOCOL_VERSION_OFFSET,
7
+ COOKIE_FORMAT, COOKIE_OFFSET,
8
+ TERMINATOR_BYTE_FORMAT, TERMINATOR_BYTE_OFFSET,
9
+ )
10
+
11
+ class ServerStateRequest:
12
+ def __init__(self, raw_request: bytes):
13
+ self.raw_request = raw_request
14
+
15
+ @property
16
+ def ProtocolMagic(self) -> str:
17
+ return struct.unpack_from(PROTOCOL_MAGIC_FORMAT, self.raw_request, PROTOCOL_MAGIC_OFFSET)[0]
18
+
19
+ @property
20
+ def MessageType(self) -> int:
21
+ return struct.unpack_from(MESSAGE_TYPE_FORMAT, self.raw_request, MESSAGE_TYPE_OFFSET)[0]
22
+
23
+ @property
24
+ def ProtocolVersion(self) -> int:
25
+ return struct.unpack_from(PROTOCOL_VERSION_FORMAT, self.raw_request, PROTOCOL_VERSION_OFFSET)[0]
26
+
27
+ @property
28
+ def Cookie(self) -> int:
29
+ return struct.unpack_from(COOKIE_FORMAT, self.raw_request, COOKIE_OFFSET)[0]
30
+
31
+ @property
32
+ def TerminatorByte(self) -> int:
33
+ return struct.unpack_from(TERMINATOR_BYTE_FORMAT, self.raw_request, TERMINATOR_BYTE_OFFSET)[0]
@@ -0,0 +1,80 @@
1
+ import struct
2
+
3
+ from .const import (
4
+ PROTOCOL_MAGIC_FORMAT, PROTOCOL_MAGIC_OFFSET,
5
+ MESSAGE_TYPE_FORMAT, MESSAGE_TYPE_OFFSET,
6
+ PROTOCOL_VERSION_FORMAT, PROTOCOL_VERSION_OFFSET,
7
+ COOKIE_FORMAT, COOKIE_OFFSET,
8
+ SERVER_STATE_FORMAT, SERVER_STATE_OFFSET,
9
+ SERVER_NET_CL_FORMAT, SERVER_NET_CL_OFFSET,
10
+ SERVER_FLAGS_FORMAT, SERVER_FLAGS_OFFSET,
11
+ NUM_SUB_STATES_FORMAT, NUM_SUB_STATES_OFFSET,
12
+ SUB_STATE_ID_FORMAT, SUB_STATE_ID_OFFSET,
13
+ SUB_STATE_VERSION_FORMAT, BASE_SUB_STATE_VERSION_OFFSET,
14
+ SERVER_NAME_LENGTH_FORMAT, BASE_SERVER_NAME_LENGTH_OFFSET,
15
+ SERVER_NAME_FORMAT, BASE_SERVER_NAME_OFFSET,
16
+ TERMINATOR_BYTE_FORMAT, TERMINATOR_BYTE_OFFSET,
17
+ SUB_STATES_STRUCTURE_SIZE
18
+ )
19
+
20
+ class ServerStateResponse:
21
+
22
+ def __init__(self, raw_response: bytes):
23
+ self.raw_response = raw_response
24
+
25
+ @property
26
+ def ProtocolMagic(self) -> str:
27
+ return struct.unpack_from(PROTOCOL_MAGIC_FORMAT, self.raw_response, PROTOCOL_MAGIC_OFFSET)[0]
28
+
29
+ @property
30
+ def MessageType(self) -> int:
31
+ return struct.unpack_from(MESSAGE_TYPE_FORMAT, self.raw_response, MESSAGE_TYPE_OFFSET)[0]
32
+
33
+ @property
34
+ def ProtocolVersion(self) -> int:
35
+ return struct.unpack_from(PROTOCOL_VERSION_FORMAT, self.raw_response, PROTOCOL_VERSION_OFFSET)[0]
36
+
37
+ @property
38
+ def Cookie(self) -> int:
39
+ return struct.unpack_from(COOKIE_FORMAT, self.raw_response, COOKIE_OFFSET)[0]
40
+
41
+ @property
42
+ def ServerState(self) -> int:
43
+ return struct.unpack_from(SERVER_STATE_FORMAT, self.raw_response, SERVER_STATE_OFFSET)[0]
44
+
45
+ @property
46
+ def ServerNetCL(self) -> int:
47
+ return struct.unpack_from(SERVER_NET_CL_FORMAT, self.raw_response, SERVER_NET_CL_OFFSET)[0]
48
+
49
+ @property
50
+ def ServerFlags(self) -> int:
51
+ return struct.unpack_from(SERVER_FLAGS_FORMAT, self.raw_response, SERVER_FLAGS_OFFSET)[0]
52
+
53
+ @property
54
+ def NumSubStates(self) -> int:
55
+ return struct.unpack_from(NUM_SUB_STATES_FORMAT, self.raw_response, NUM_SUB_STATES_OFFSET)[0]
56
+
57
+ @property
58
+ def SubStates(self) -> list[tuple[int, int]]:
59
+ sub_states: list[tuple[int, int]] = []
60
+ for i in range(self.NumSubStates):
61
+ sub_state = (
62
+ struct.unpack_from(SUB_STATE_ID_FORMAT, self.raw_response, SUB_STATE_ID_OFFSET + i * SUB_STATES_STRUCTURE_SIZE)[0],
63
+ struct.unpack_from(SUB_STATE_VERSION_FORMAT, self.raw_response, BASE_SUB_STATE_VERSION_OFFSET + i * SUB_STATES_STRUCTURE_SIZE)[0]
64
+ )
65
+ sub_states.append(sub_state)
66
+ return sub_states
67
+
68
+ @property
69
+ def ServerNameLength(self) -> int:
70
+ return struct.unpack_from(SERVER_NAME_LENGTH_FORMAT, self.raw_response, BASE_SERVER_NAME_LENGTH_OFFSET + self.NumSubStates * SUB_STATES_STRUCTURE_SIZE)[0]
71
+
72
+ @property
73
+ def ServerName(self) -> str:
74
+ calculated_server_name_format = SERVER_NAME_FORMAT.format(self.ServerNameLength + 1)
75
+ calculated_server_name_offset = BASE_SERVER_NAME_OFFSET + self.NumSubStates * SUB_STATES_STRUCTURE_SIZE
76
+ return struct.unpack_from(calculated_server_name_format, self.raw_response, calculated_server_name_offset)[0].decode('utf-8')
77
+
78
+ @property
79
+ def TerminatorByte(self) -> int:
80
+ return struct.unpack_from(TERMINATOR_BYTE_FORMAT, self.raw_response, TERMINATOR_BYTE_OFFSET)[0]
@@ -0,0 +1,21 @@
1
+ import asyncio
2
+
3
+ async def udp_query(host: str, port: int, data: bytes, timeout: float = 2.0) -> bytes | None:
4
+
5
+ async_loop = asyncio.get_running_loop()
6
+ future = async_loop.create_future()
7
+
8
+ class UdpProtocol(asyncio.DatagramProtocol):
9
+ def datagram_received(self, data: bytes, addr: tuple[str, int]):
10
+ future.set_result(data)
11
+
12
+ transport, _ = await async_loop.create_datagram_endpoint(UdpProtocol, remote_addr=(host, port))
13
+
14
+ transport.sendto(data)
15
+
16
+ try:
17
+ return await asyncio.wait_for(future, timeout)
18
+ except TimeoutError:
19
+ return None
20
+ finally:
21
+ transport.close()
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiosatisfactory
3
+ Version: 0.1.0
4
+ Summary: Async client for Satisfactory dedicated server APIs
5
+ Author: Rikys
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Rikys/aiosatisfactory
8
+ Project-URL: Documentation, https://github.com/Rikys/aiosatisfactory#readme
9
+ Project-URL: Issues, https://github.com/Rikys/aiosatisfactory/issues
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: aiohttp>=3.9.0
14
+ Dynamic: license-file
15
+
16
+ # aiosatisfactory
17
+
18
+ This is an async Python library for Satisfactory dedicated server's APIs.
19
+
20
+ This work is based off the official documentation that is provided with the game files or is also available on the official [wiki](https://satisfactory.wiki.gg)
21
+
22
+ ## Lightweight API [(docs)](https://satisfactory.wiki.gg/wiki/Dedicated_servers/Lightweight_Query_API)
23
+ This API should be used to poll the server state before making most of the https requests \
24
+ No errors are raised but you must check if the query was succesful
25
+
26
+ ### Usage:
27
+ ```python
28
+ from aiosatisfactory import SatisfactoryServer
29
+ import time
30
+
31
+ client = SatisfactoryServer("server.ip")
32
+ query = await client.lightweight.query(time.time_ns()) #We use time to generate a Cookie
33
+ if query is not None:
34
+ print(query.response)
35
+ ```
36
+
37
+ ## Https API [(docs)](https://satisfactory.wiki.gg/wiki/Dedicated_servers/HTTPS_API)
38
+ This API requires the *session* parameter to be set in the *SatisfactoryServer* constructor \
39
+ It does raise an *ErrorResponse* exeption if the function you try to execute fails
40
+
41
+ ### Usage:
42
+ ```python
43
+ import asyncio
44
+ from aiosatisfactory import SatisfactoryServer
45
+
46
+ async def main():
47
+
48
+ with aiohttp.ClientSession() as session:
49
+ client = SatisfactoryServer("server.ip", session=session)
50
+ try:
51
+ response = await client.https.api.health_check()
52
+ print(response.health)
53
+ except ErrorResponse as e:
54
+ print(f"Error: {e.error_code, e.error_message, e.error_details}")
55
+
56
+ asyncio.run(main())
57
+ ```
@@ -0,0 +1,14 @@
1
+ aiosatisfactory/__init__.py,sha256=TtZYPvyXN0BC5mk7lBMP6yqPEQtez4nKpod-OiPDyPc,669
2
+ aiosatisfactory/https/__init__.py,sha256=rowIX18jcfJnptWdXddDuKcd0Ff6hdzRnrrT0aE7eJg,1138
3
+ aiosatisfactory/https/api.py,sha256=FicCECnrCboN_8wUs9fAfuAZXD07nLGEhDfdg-lSvGo,11286
4
+ aiosatisfactory/https/models.py,sha256=_NqEvyoRHlRFZ7PxYniHEPBfvd14pKTDzEf4eGya3z4,2324
5
+ aiosatisfactory/lightweight/__init__.py,sha256=JT0Ege89Jc3fMLTerHa6Uf46XD-mZUI6-gB7TqnMOFA,1145
6
+ aiosatisfactory/lightweight/const.py,sha256=N6215_KBw6KzSL-Gx1_wVwZqN67SnZWRd3t-Qi9jGyU,2259
7
+ aiosatisfactory/lightweight/request.py,sha256=0qRyGY1E2vmmbQDA22_ECwUOUtC67H37m4JZaqYCb8I,1131
8
+ aiosatisfactory/lightweight/response.py,sha256=_KfiGmChv2aUGAR5g3TC4DyVZF7p11vwA86aqgk9mm0,3316
9
+ aiosatisfactory/lightweight/udp.py,sha256=QKodLyX7spC9497d8AOQ2uyLBWLIwAesjET_wk9QKT0,639
10
+ aiosatisfactory-0.1.0.dist-info/licenses/LICENSE,sha256=n0Ob9a17RbQdD9wWCWxcoCa4Ue3oQ9q7wx2vA0L8ZnA,1062
11
+ aiosatisfactory-0.1.0.dist-info/METADATA,sha256=0d3t8O2890YKq8r5keDepPPrtKHih2dkTeZcf1caQu8,2021
12
+ aiosatisfactory-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ aiosatisfactory-0.1.0.dist-info/top_level.txt,sha256=t4_yQXShVWTN_tfB33k_hLZ63HudgMMy5jxuZjKnS9U,16
14
+ aiosatisfactory-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Rikys
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ aiosatisfactory