diff --git a/.gitignore b/.gitignore index 39aebb69..e5111608 100644 --- a/.gitignore +++ b/.gitignore @@ -7,13 +7,13 @@ __pycache__ *.gba .last-requirements-check +swagger.json build/ profiles/*/ dist/ logs/ mgba/ -stats/ -stream/ - roms/* !roms/.gitkeep +stats/ +stream/ diff --git a/modules/main.py b/modules/main.py index baa40d7b..90cef232 100644 --- a/modules/main.py +++ b/modules/main.py @@ -33,7 +33,7 @@ def main_loop() -> None: Thread(target=discord_rich_presence).start() if config.obs.http_server.enable: - from modules.http import http_server + from modules.web.http import http_server Thread(target=http_server).start() diff --git a/modules/http.py b/modules/web/http.py similarity index 55% rename from modules/http.py rename to modules/web/http.py index 527b1adb..a4592593 100644 --- a/modules/http.py +++ b/modules/web/http.py @@ -1,15 +1,21 @@ import io +import json import time from pathlib import Path from flask import Flask, jsonify, request, Response from flask_cors import CORS +from apispec import APISpec +from apispec_webframeworks.flask import FlaskPlugin +from flask_swagger_ui import get_swaggerui_blueprint + from modules.config import available_bot_modes from modules.context import context from modules.data.map import MapRSE, MapFRLG +from modules.files import write_file from modules.game import _event_flags -from modules.http_stream import add_subscriber +from modules.web.http_stream import add_subscriber from modules.items import get_item_bag, get_item_storage from modules.main import work_queue from modules.map import get_map_data @@ -20,17 +26,63 @@ from modules.state_cache import state_cache from modules.player import get_player, get_player_avatar from modules.pokedex import get_pokedex +from modules.version import pokebot_name, pokebot_version def http_server() -> None: """ Run Flask server to make bot data available via HTTP requests. """ + server = Flask(__name__) CORS(server) + SWAGGER_URL = "/docs" + API_URL = f"http://{context.config.obs.http_server.ip}:{context.config.obs.http_server.port}/swagger.json" + + spec = APISpec( + title=f"{pokebot_name} API", + version=pokebot_version, + openapi_version="3.0.3", + info=dict( + description=f"{pokebot_name} API", + version=pokebot_version, + license=dict( + name="GNU General Public License v3.0", url="https://github.com/40Cakes/pokebot-gen3/blob/main/LICENSE" + ), + ), + servers=[ + dict( + description=f"{pokebot_name} server", + url=f"http://{context.config.obs.http_server.ip}:{context.config.obs.http_server.port}", + ) + ], + plugins=[FlaskPlugin()], + ) + + swaggerui_blueprint = get_swaggerui_blueprint(SWAGGER_URL, API_URL, config={"app_name": f"{pokebot_name} API"}) + @server.route("/stream_events", methods=["GET"]) def http_get_events_stream(): + """ + --- + get: + description: Continuously receive updates from a topic in the form of server-sent events. See [here](https://github.com/40Cakes/pokebot-gen3/blob/main/modules/web/Readme.md) for more information on Event Streams. + parameters: + - in: query + name: topic + schema: + type: string + required: true + description: topic + responses: + 200: + content: + text/event-stream: + schema: + type: array + """ + subscribed_topics = request.args.getlist("topic") if len(subscribed_topics) == 0: return Response("You need to provide at least one `topic` parameter in the query.", status=422) @@ -53,6 +105,25 @@ def stream(): @server.route("/stream_video", methods=["GET"]) def http_get_video_stream(): + """ + --- + get: + description: Stream emulator video. + parameters: + - in: query + name: fps + schema: + type: integer + required: true + description: fps + default: 30 + responses: + 200: + content: + text/event-stream: + schema: + type: array + """ fps = request.args.get("fps", "30") if not fps.isdigit(): fps = 30 @@ -78,6 +149,16 @@ def stream(): @server.route("/player", methods=["GET"]) def http_get_player(): + """ + --- + get: + description: Returns player rarely-changing player data such as name, TID, SID etc. + responses: + 200: + content: + application/json: {} + """ + cached_player = state_cache.player if cached_player.age_in_frames > 5: work_queue.put_nowait(get_player) @@ -92,6 +173,16 @@ def http_get_player(): @server.route("/player_avatar", methods=["GET"]) def http_get_player_avatar(): + """ + --- + get: + description: Returns player avatar data, on-map character data such as map bank, map ID, X/Y coordinates + responses: + 200: + content: + application/json: {} + """ + cached_avatar = state_cache.player_avatar if cached_avatar.age_in_frames > 5: work_queue.put_nowait(get_player_avatar) @@ -106,6 +197,16 @@ def http_get_player_avatar(): @server.route("/items", methods=["GET"]) def http_get_bag(): + """ + --- + get: + description: Returns a list of all items in the bag and PC, and their quantities. + responses: + 200: + content: + application/json: {} + """ + cached_bag = state_cache.item_bag cached_storage = state_cache.item_storage if cached_bag.age_in_seconds > 1: @@ -122,8 +223,38 @@ def http_get_bag(): } ) + @server.route("/party", methods=["GET"]) + def http_get_party(): + """ + --- + get: + description: Returns a detailed list of all Pokémon in the party. + responses: + 200: + content: + application/json: {} + """ + + cached_party = state_cache.party + if cached_party.age_in_frames > 5: + work_queue.put_nowait(get_party) + while cached_party.age_in_frames > 5: + time.sleep(0.05) + + return jsonify([p.to_dict() for p in cached_party.value]) + @server.route("/map", methods=["GET"]) def http_get_map(): + """ + --- + get: + description: Returns data about the map and current tile that the player avatar is standing on. + responses: + 200: + content: + application/json: {} + """ + cached_avatar = state_cache.player_avatar if cached_avatar.age_in_frames > 5: work_queue.put_nowait(get_player_avatar) @@ -144,6 +275,31 @@ def http_get_map(): @server.route("/map//") def http_get_map_by_group_and_number(map_group: int, map_number: int): + """ + --- + get: + description: Returns detailed information about a specific map. + parameters: + - in: path + name: map_group + schema: + type: integer + required: true + default: 1 + description: Map Group ID + - in: path + name: map_number + schema: + type: integer + required: true + default: 1 + description: Map Number ID + responses: + 200: + content: + application/json: {} + """ + if context.rom.game_title in ["POKEMON EMER", "POKEMON RUBY", "POKEMON SAPP"]: maps_enum = MapRSE else: @@ -162,18 +318,18 @@ def http_get_map_by_group_and_number(map_group: int, map_number: int): } ) - @server.route("/party", methods=["GET"]) - def http_get_party(): - cached_party = state_cache.party - if cached_party.age_in_frames > 5: - work_queue.put_nowait(get_party) - while cached_party.age_in_frames > 5: - time.sleep(0.05) - - return jsonify([p.to_dict() for p in cached_party.value]) - @server.route("/pokedex", methods=["GET"]) def http_get_pokedex(): + """ + --- + get: + description: Returns the player's Pokédex (seen/caught). + responses: + 200: + content: + application/json: {} + """ + cached_pokedex = state_cache.pokedex if cached_pokedex.age_in_seconds > 1: work_queue.put_nowait(get_pokedex) @@ -184,6 +340,16 @@ def http_get_pokedex(): @server.route("/pokemon_storage", methods=["GET"]) def http_get_pokemon_storage(): + """ + --- + get: + description: Returns detailed information about all boxes in PC storage. + responses: + 200: + content: + application/json: {} + """ + cached_storage = state_cache.pokemon_storage if cached_storage.age_in_frames > 5: work_queue.put_nowait(get_pokemon_storage) @@ -194,6 +360,16 @@ def http_get_pokemon_storage(): @server.route("/opponent", methods=["GET"]) def http_get_opponent(): + """ + --- + get: + description: Returns detailed information about the current/recent encounter. + responses: + 200: + content: + application/json: {} + """ + if state_cache.game_state.value != GameState.BATTLE: result = None else: @@ -207,26 +383,93 @@ def http_get_opponent(): @server.route("/game_state", methods=["GET"]) def http_get_game_state(): + """ + --- + get: + description: Returns game state information. + responses: + 200: + content: + application/json: {} + """ + return jsonify(get_game_state().name) @server.route("/encounter_log", methods=["GET"]) def http_get_encounter_log(): + """ + --- + get: + description: Returns a detailed list of the recent 10 Pokémon encounters. + responses: + 200: + content: + application/json: {} + """ + return jsonify(total_stats.get_encounter_log()) @server.route("/shiny_log", methods=["GET"]) def http_get_shiny_log(): + """ + --- + get: + description: Returns a detailed list of all shiny Pokémon encounters. + responses: + 200: + content: + application/json: {} + """ + return jsonify(total_stats.get_shiny_log()) @server.route("/encounter_rate", methods=["GET"]) def http_get_encounter_rate(): + """ + --- + get: + description: Returns the current encounter rate (encounters per hour). + responses: + 200: + content: + application/json: {} + """ + return jsonify({"encounter_rate": total_stats.get_encounter_rate()}) @server.route("/stats", methods=["GET"]) def http_get_stats(): + """ + --- + get: + description: Returns returns current phase and total statistics. + responses: + 200: + content: + application/json: {} + """ + return jsonify(total_stats.get_total_stats()) @server.route("/event_flags", methods=["GET"]) def http_get_event_flags(): + """ + --- + get: + description: Returns all event flags for the current save file (optional parameter `?flag=FLAG_NAME` to get a specific flag). + parameters: + - in: query + name: flag + schema: + type: string + required: false + description: flag_name + responses: + 200: + content: + application/json: {} + """ + flag = request.args.get("flag") if flag and flag in _event_flags: @@ -241,6 +484,16 @@ def http_get_event_flags(): @server.route("/emulator", methods=["GET"]) def http_get_emulator(): + """ + --- + get: + description: Returns information about the emulator core + the current loaded game/profile. + responses: + 200: + content: + application/json: {} + """ + if context.emulator is None: return jsonify(None) else: @@ -266,6 +519,16 @@ def http_get_emulator(): @server.route("/fps", methods=["GET"]) def http_get_fps(): + """ + --- + get: + description: Returns a list of emulator FPS (frames per second), in intervals of 1 second, for the previous 60 seconds. + responses: + 200: + content: + application/json: {} + """ + if context.emulator is None: return jsonify(None) else: @@ -273,6 +536,34 @@ def http_get_fps(): @server.route("/emulator", methods=["POST"]) def http_post_emulator(): + """ + --- + post: + description: Change some settings for the emulator. Accepts a JSON payload. + requestBody: + description: JSON payload + content: + application/json: + schema: {} + examples: + emulation_speed: + summary: Set emulation speed to 4x. + value: {"emulation_speed": 4} + bot_mode: + summary: Set bot bode to spin. + value: {"bot_mode": "Spin"} + video_enabled: + summary: Enable video. + value: {"video_enabled": true} + audio_enabled: + summary: Disable audio. + value: {"audio_enabled": false} + responses: + 200: + content: + application/json: {} + """ + new_settings = request.json if not isinstance(new_settings, dict): return Response("This endpoint expects a JSON object as its payload.", status=422) @@ -305,25 +596,42 @@ def http_post_emulator(): return http_get_emulator() - @server.route("/routes", methods=["GET"]) - def http_get_routes(): - routes = {} - - for route in server.url_map._rules: - routes[route.rule] = {} - routes[route.rule]["functionName"] = route.endpoint - routes[route.rule]["methods"] = list(route.methods) - - routes.pop("/static/") - - return jsonify(routes) - @server.route("/", methods=["GET"]) def http_index(): - index_file = Path(__file__).parent / "web" / "http_example.html" + index_file = Path(__file__).parent / "http_example.html" with open(index_file, "rb") as file: return Response(file.read(), content_type="text/html; charset=utf-8") + @server.route("/swagger.json", methods=["GET"]) + def swagger_json(): + index_file = Path(__file__).parent / "swagger.json" + with open(index_file, "rb") as file: + return Response(file.read(), content_type="application/json; charset=utf-8") + + with server.test_request_context(): + spec.path(view=http_get_player) + spec.path(view=http_get_player_avatar) + spec.path(view=http_get_bag) + spec.path(view=http_get_party) + spec.path(view=http_get_map) + spec.path(view=http_get_map_by_group_and_number) + spec.path(view=http_get_pokedex) + spec.path(view=http_get_pokemon_storage) + spec.path(view=http_get_opponent) + spec.path(view=http_get_game_state) + spec.path(view=http_get_encounter_log) + spec.path(view=http_get_shiny_log) + spec.path(view=http_get_encounter_rate) + spec.path(view=http_get_stats) + spec.path(view=http_get_event_flags) + spec.path(view=http_get_fps) + spec.path(view=http_get_emulator) + spec.path(view=http_post_emulator) + spec.path(view=http_get_events_stream) + spec.path(view=http_get_video_stream) + + write_file(Path(__file__).parent / "swagger.json", json.dumps(spec.to_dict(), indent=4)) + server.register_blueprint(swaggerui_blueprint) server.run( debug=False, threaded=True, diff --git a/modules/http_stream.py b/modules/web/http_stream.py similarity index 100% rename from modules/http_stream.py rename to modules/web/http_stream.py diff --git a/requirements.py b/requirements.py index c322318d..f18524cf 100644 --- a/requirements.py +++ b/requirements.py @@ -29,6 +29,9 @@ "requests~=2.31.0", "pyperclip3~=0.4.1", "plyer~=2.1.0", + "apispec~=6.3.0", + "apispec-webframeworks~=0.5.2", + "flask-swagger-ui~=4.11.1", ] if platform.system() == "Windows":