diff --git a/.gitignore b/.gitignore index 65b49c1..efbb171 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ ./old_bot +.idea/ +__pycache__ diff --git a/Dockerfile b/Dockerfile index f7b9476..8c88462 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt COPY ./application /code/application COPY ./main.py /code/ -ENTRYPOINT ["python", "/code/main.py"] +ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"] diff --git a/README.md b/README.md index cfdbd22..0cc3a12 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,44 @@ ## Client-Server-Kommunikation -- der Client verbindet sich mit dem Server per websocket -- zu Beginn sendet der client seine Platform und version als json zum server: - ```json - {"platform":"python", "version":999} - ``` -- sobald der client in 0 Lage ist ein pixel zu setzen schickt dieser ein `request_pixel` an den server - - der Server antwortet dann mit dem zu setzenden pixel als json, e.g.: +- Der Client verbindet sich mit dem Server per websocket +- Zu Beginn sendet der client seine Platform und version als json zum server: ```json { - "operation":"pixel", - "data":{ + "operation": "handshake", + "data": { + "platform": "python", + "version": 999 + } +} +``` + +- Sollte der Server feststellen, dass der Client eine alte Version verwendet, sendet er diesem eine Update aufforderung zurück: +```json +{ + "operation": "notify-update" +} +``` + +- sobald der client in 0 Lage ist ein pixel zu setzen schickt dieser ein `request-pixel` an den server +```json +{ + "operation": "request-pixel", + "user": "" +} + ``` + +- der Server antwortet dann mit dem zu setzenden pixel als json, e.g.: +```json +{ + "operation": "place-pixel", + "data": { "x": 0, "y": 857, "color": 4, "priority": 1 - } + }, + "user": "" } ``` - - wenn kein Pixel existiert, wird `null` zurückgesendet. + +- wenn kein Pixel existiert, wird `{}` zurückgesendet. diff --git a/application/connections/__init__.py b/application/api/__init__.py similarity index 100% rename from application/connections/__init__.py rename to application/api/__init__.py diff --git a/application/api/commands.py b/application/api/commands.py new file mode 100644 index 0000000..78e8d1c --- /dev/null +++ b/application/api/commands.py @@ -0,0 +1,32 @@ +from application.api.config import ServerConfig +from application.canvas.canvas import Canvas +from application.color import get_color_from_index + + +async def request_pixel(canvas: Canvas): + pixel = await canvas.pop_mismatched_pixel() + if pixel: + return { + 'x': pixel['x'], + 'y': pixel['y'], + 'color': get_color_from_index(pixel['color_index']).value['id'] + } + else: + return {} + + +# no-op +async def handshake(): + pass + + +def ping(): + return { + 'pong': True + } + + +def version_check(settings: ServerConfig, data: dict): + client_version = data.get('version', -1) + # wenn der client nix schickt nehmen wir an, dass er in ordnung ist + return client_version < 0 or client_version >= settings.min_version diff --git a/application/api/config.py b/application/api/config.py new file mode 100644 index 0000000..868a3aa --- /dev/null +++ b/application/api/config.py @@ -0,0 +1,27 @@ +from pydantic import BaseSettings + + +class ServerConfig(BaseSettings): + remote_config_url: str = 'https://placede.github.io/pixel/pixel.json' + canvas_update_interval: int = 10 + min_version: int = 0 + + +def get_graphql_config(): + return { + "id": "1", + "type": "start", + "payload": { + "variables": { + "input": { + "channel": { + "teamOwner": "AFD2022", + "category": "CONFIG", + } + } + }, + "extensions": {}, + "operationName": "configuration", + "query": "subscription configuration($input: SubscribeInput!) {\n subscribe(input: $input) {\n id\n ... on BasicMessage {\n data {\n __typename\n ... on ConfigurationMessageData {\n colorPalette {\n colors {\n hex\n index\n __typename\n }\n __typename\n }\n canvasConfigurations {\n index\n dx\n dy\n __typename\n }\n canvasWidth\n canvasHeight\n __typename\n }\n }\n __typename\n }\n __typename\n }\n}\n", + }, + } \ No newline at end of file diff --git a/application/api/connection_manager.py b/application/api/connection_manager.py new file mode 100644 index 0000000..ad00c92 --- /dev/null +++ b/application/api/connection_manager.py @@ -0,0 +1,33 @@ +from typing import List + +from fastapi import WebSocket + + +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + self.advertised_accounts: dict = {} + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def set_advertised_accounts(self, websocket: WebSocket, count): + self.advertised_accounts[websocket] = count + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + self.advertised_accounts.pop(websocket) + + async def send_message_to(self, message: str, websocket: WebSocket): + await websocket.send_text(message) + + async def broadcast(self, message: str): + for connection in self.active_connections: + await connection.send_text(message) + + def connection_count(self): + return len(self.active_connections) + + def advertised_account_count(self): + return sum(self.advertised_accounts.values()) diff --git a/application/canvas/canvas.py b/application/canvas/canvas.py index c37d6df..f524d3c 100644 --- a/application/canvas/canvas.py +++ b/application/canvas/canvas.py @@ -10,7 +10,6 @@ from PIL import Image from application.color import get_matching_color, Color, get_color_from_index -from application.static_stuff import CANVAS_UPDATE_INTERVAL from application.target_configuration.target_configuration import TargetConfiguration BOARD_SIZE_X = 2000 @@ -95,7 +94,7 @@ async def update_board(self): """ Fetch the current state of the board/canvas for the requed areas """ - if self.last_update + CANVAS_UPDATE_INTERVAL >= time.time(): + if self.last_update + self.target_configuration.settings.canvas_update_interval >= time.time(): return False await self.update_access_token() diff --git a/application/connections/websocket_server.py b/application/connections/websocket_server.py deleted file mode 100644 index cf3b774..0000000 --- a/application/connections/websocket_server.py +++ /dev/null @@ -1,99 +0,0 @@ -import asyncio -import hashlib -import json -import sys -import traceback -from typing import Dict, Any - -import websockets.server -from websockets import serve -from websockets.server import WebSocketServerProtocol - -from application.color import get_color_from_index -from application.canvas.canvas import Canvas - -DEFAULT_PORT = 5555 -DEFAULT_HOST = "localhost" - - -class Server: - """ - Websocket server, dieser managed die Verbindung zu den client Bots und teilt denen auf Anfrage neue Pixel zu. - """ - __slots__ = ("config", "port", "__server_loop", "__server", "host", "provider", "__client_count") - __client_count: int - config: Dict[str, Any] - port: int - host: str - provider: Canvas - - def __init__(self, provider: Canvas, config: Dict[str, Any]): - self.provider = provider - self.config = config - self.port = config.get("port", DEFAULT_PORT) - self.host = config.get("host", DEFAULT_HOST) - self.__server_loop = None - self.__server = None - self.__client_count = 0 - - async def run(self, looper: asyncio.AbstractEventLoop): - """ - erstellt den server und lässt diesen unendlich laufen. Sollte evtl. in einem eigenen Thread aufgerufen werden. - """ - async with serve(self.__handler, self.host, self.port): - while True: - await asyncio.sleep(1000000) - #await asyncio.Future() - - async def __handler(self, socket: WebSocketServerProtocol): - bot_count = 0 - - try: - # TODO: check for update availability. - - async for msg in socket: - req = json.loads(msg) - - if req.get("operation") == "request-pixel": - print(msg) - pixel = await self.provider.pop_mismatched_pixel() - if pixel: - data = { - "x": pixel["x"], - "y": pixel["y"], - "color": get_color_from_index(pixel["color_index"]).value["id"] - } - - await socket.send(json.dumps(Server.__wrap_data(data, req.get("user", "")))) - else: - await socket.send("{}") - - elif req.get("operation") == "handshake": - bot_count = abs(req["data"].get("useraccounts", 1)) - self.__client_count += bot_count - print(f"{bot_count} New Client(s) connected! New bot count: {self.__client_count}") - - elif req.get("operation") == "get-botcount" and hashlib.sha3_512(req.get("pw").encode()).hexdigest() == "bea976c455d292fdd15256d3263cb2b70f051337f134b0fa9678d5eb206b4c45ebd213694af9cf6118700fc8488809be9195c7eae44a882c6be519ba09b68e47": - await socket.send(json.dumps({"amount": self.__client_count})) - - elif req.get("operation") == "ping": - await socket.send(json.dumps({"pong": True})) - - except websockets.ConnectionClosed: - pass - except Exception as e: - traceback.print_exception(*sys.exc_info()) - finally: - self.__client_count -= bot_count - print(f"{bot_count} Client(s) lost! New bot count: {self.__client_count}") - - @staticmethod - def __wrap_data(data: dict, user: str, operation: str = "place-pixel") -> dict: - return { - "operation": operation, - "data": data, - "user": user - } - - def get_bot_count(self) -> int: - return self.__client_count diff --git a/application/frontend/__init__.py b/application/frontend/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/application/frontend/frontend.py b/application/frontend/frontend.py deleted file mode 100644 index 979b131..0000000 --- a/application/frontend/frontend.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import FastAPI - -app = FastAPI() - - -@app.get("/") -async def user_count(): - return diff --git a/application/static_stuff.py b/application/static_stuff.py deleted file mode 100644 index 5cebab5..0000000 --- a/application/static_stuff.py +++ /dev/null @@ -1,21 +0,0 @@ -GRAPHQL_GET_CONFIG = { - "id": "1", - "type": "start", - "payload": { - "variables": { - "input": { - "channel": { - "teamOwner": "AFD2022", - "category": "CONFIG", - } - } - }, - "extensions": {}, - "operationName": "configuration", - "query": "subscription configuration($input: SubscribeInput!) {\n subscribe(input: $input) {\n id\n ... on BasicMessage {\n data {\n __typename\n ... on ConfigurationMessageData {\n colorPalette {\n colors {\n hex\n index\n __typename\n }\n __typename\n }\n canvasConfigurations {\n index\n dx\n dy\n __typename\n }\n canvasWidth\n canvasHeight\n __typename\n }\n }\n __typename\n }\n __typename\n }\n}\n", - }, -} - - -target_configuration_url = "https://placede.github.io/pixel/pixel.json" -CANVAS_UPDATE_INTERVAL = 10 diff --git a/application/target_configuration/target_configuration.py b/application/target_configuration/target_configuration.py index 3cbd37e..02f4ddd 100644 --- a/application/target_configuration/target_configuration.py +++ b/application/target_configuration/target_configuration.py @@ -1,12 +1,10 @@ import json import random import time -import asyncio import aiohttp -import requests -from application import static_stuff +from application.api.config import ServerConfig UPDATE_INTERVAL = 60 @@ -17,16 +15,18 @@ class TargetConfiguration: Is refreshed periodically by pulling it from a server """ - def __init__(self): + def __init__(self, settings: ServerConfig): self.last_update = 0 self.config = {} self.pixels = [] + self.settings = settings + print(settings) async def get_config(self, ignore_time: bool = False): """ Get the config and refresh it first if necessary """ - if self.last_update + UPDATE_INTERVAL < time.time() and not ignore_time: + if self.last_update + self.settings.canvas_update_interval < time.time() and not ignore_time: await self.refresh_config() self.last_update = time.time() @@ -47,14 +47,14 @@ async def refresh_config(self): """ print("\nRefreshing target configuration...\n") - url = static_stuff.target_configuration_url + url = self.settings.remote_config_url if url.startswith("http"): async with aiohttp.ClientSession() as session: async with session.get(url) as resp: json_data = await resp.json() if resp.status != 200: - print("Error: Could not get config file from " + static_stuff.target_configuration_url) + print("Error: Could not get config file from " + self.settings.remote_config_url) return # parse config file diff --git a/main.py b/main.py index b22d750..3b233b9 100644 --- a/main.py +++ b/main.py @@ -1,37 +1,103 @@ -# library imports - -from __future__ import annotations - import asyncio +import hashlib +import json -# our imports -from application.target_configuration.target_configuration import TargetConfiguration -from application.canvas import canvas -from application.connections import websocket_server +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse -# from fastapi import +from application.api.commands import request_pixel, ping, version_check +from application.api.connection_manager import ConnectionManager +from application.api.config import ServerConfig +from application.canvas.canvas import Canvas +from application.target_configuration.target_configuration import TargetConfiguration +app = FastAPI() +connection_manager = ConnectionManager() +config = ServerConfig() +canvas: Canvas -# create target_configuration -target_configuration = TargetConfiguration() -# manage r/place canvas -monalisa = canvas.Canvas(target_configuration) -# server for remote bot connections -server = websocket_server.Server(monalisa, {"host": "0.0.0.0", "port": 8080}) -async def main_loop(): +async def update_canvas(monalisa: Canvas): while True: - # update board if it needs to be updated - if await monalisa.update_board(): - await monalisa.calculate_mismatched_pixels() - await asyncio.sleep(30) - - -looper = asyncio.new_event_loop() -looper.create_task(main_loop()) -looper.create_task(server.run(looper)) -try: - looper.run_forever() -except (KeyboardInterrupt, RuntimeError): - print("Exiting!") + try: + if await monalisa.update_board(): + await monalisa.calculate_mismatched_pixels() + await asyncio.sleep(30) + finally: + print('There was an error updating the canvas.') + + +@app.on_event('startup') +async def startup(): + global canvas + target_config = TargetConfiguration(config) + canvas = Canvas(target_config) + print('Scheduling canvas update') + asyncio.create_task(update_canvas(canvas)) + + +@app.websocket('/live') +async def live_endpoint(websocket: WebSocket): + await connection_manager.connect(websocket) + + try: + while True: + data = await websocket.receive_json() + print(f'RX: {json.dumps(data)}') + if 'operation' in data: + op = data['operation'] + response = None + + if op == 'request-pixel': + response = format_response( + 'place-pixel', + data.get('user', ''), + await request_pixel(canvas) + ) + elif op == 'handshake': + metadata = data.get('data', {}) + advertised_count = metadata.get('useraccounts', 1) + if not version_check(config, metadata): + response = format_response( + 'notify-update', + data.get('user', ''), + { + 'min_version', config.min_version + } + ) + await websocket.close(4001) + return + connection_manager.set_advertised_accounts(websocket, advertised_count) + elif op == 'ping': + response = ping() + # eigtl. durch /users/count deprecated + elif op == 'get-botcount' and password_check(data.get("pw", '')): + response = {'amount': connection_manager.advertised_account_count()} + + if response is not None: + print(f'TX: {json.dumps(response)}') + await websocket.send_json(response) + finally: + connection_manager.disconnect(websocket) + + +@app.get('/users/count') +async def get_users_count(): + return JSONResponse(content={ + 'connections': connection_manager.connection_count(), + 'advertised_accounts': connection_manager.advertised_account_count() + }) + + +def format_response(op: str, user: str, data: any): + return { + 'operation': op, + 'data': data, + 'user': user + } + + +def password_check(password): + return hashlib.sha3_512( + password.encode()).hexdigest() == "bea976c455d292fdd15256d3263cb2b70f051337f134b0fa9678d5eb206b4c45ebd213694af9cf6118700fc8488809be9195c7eae44a882c6be519ba09b68e47" diff --git a/requirements.txt b/requirements.txt index 64d5164..bd2f52f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ -fastapi +fastapi~=0.75.1 uvicorn -pillow +pillow~=9.1.0 requests -websockets +websockets~=10.2 websocket-client -aiohttp +aiohttp~=3.8.1 + +pydantic~=1.9.0 \ No newline at end of file