commit f73902bef24e3b29a5ea78bb3b5f2d97500f02dd Author: Kiri Date: Sat Jun 29 22:50:35 2024 -0700 A rather alarming amount of work for one day. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..770fb05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Godot 4+ specific ignores +.godot/ +__pycache__ + diff --git a/Control.gd b/Control.gd new file mode 100644 index 0000000..37d1cb0 --- /dev/null +++ b/Control.gd @@ -0,0 +1,51 @@ +extends Control + +#var packet_socket_server : KiriPacketSocket +var wrapper_instance : KiriPythonWrapperInstance + +#var active_connections = [] + +func _ready(): + + var jrpc : KiriJSONRPC = KiriJSONRPC.new() + var x = jrpc.make_request("make_request", [ "bar", "wharrgarbl", 123 ], 1) + #var x = jrpc.make_request("can_translate_message", [], 1) + print(x) + + var c = jrpc.process_action_safer(x) + print("process action: ", c) + + #packet_socket_server = KiriPacketSocket.new() + ##packet_socket_server.start_server(["127.0.0.1", 9506]) + #packet_socket_server.start_client(["127.0.0.1", 9506]) + #active_connections.append(packet_socket_server) + + wrapper_instance = KiriPythonWrapperInstance.new( + "/storage/git2/GodotJSONRPCTest/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/test_module/__init__.py") + + wrapper_instance.start_process() + +func _process(_delta): + pass + + #var next_connection = packet_socket_server.get_next_server_connection() + #if next_connection: + #active_connections.append(next_connection) + #print("GOT A NEW CONNECTION!") + + #for conn in active_connections: + #var p = conn.get_next_packet() + #while p: + #print("PACKET: ", p) + #conn.send_packet(p) + #p = conn.get_next_packet() + + wrapper_instance.poll() + #print(wrapper_instance.get_status()) + + wrapper_instance.call_rpc_async("func_to_call", [12345], func(stuff): + print(stuff.response) + ) + + #wrapper_instance.call_rpc_sync("func_to_call", [12345]) + diff --git a/KiriJSONRPC.gd b/KiriJSONRPC.gd new file mode 100644 index 0000000..db1611c --- /dev/null +++ b/KiriJSONRPC.gd @@ -0,0 +1,50 @@ +# KiriJSONRPC +# +# This just wraps JSONRPC and adds a little more sanity-checking, like +# preventing the JSONRPC's own methods from being called by an RPC. + +extends JSONRPC +class_name KiriJSONRPC + +func process_action_safer(action: Variant, recurse: bool = false) -> Variant: + + if action is String: + action = JSON.parse_string(action) + + # Do some basic type sanity checking. + var invalid_request : bool = false + if not (action is Dictionary): + invalid_request = true + elif not ("method" in action): + invalid_request = true + elif not (action["method"] is String): + invalid_request = true + + if invalid_request: + return make_response_error( + JSONRPC.INVALID_REQUEST, + "Invalid request") + + # Exclude JSONRPC's own built-in methods. Why the heck are these allowed + # here? + var method_name = action["method"] + var id = action["id"] + var bad_message_names = [ + "make_notification", + "make_request", + "make_response", + "make_response_error", + "process_action", + "process_string", + "set_scope" + ] + + if method_name in bad_message_names: + return make_response_error( + JSONRPC.METHOD_NOT_FOUND, + "Method not found: " + method_name, + id) + + return process_action(action, recurse) + + diff --git a/addons/kiripythonrpcwrapper/KiriPacketSocket/KiriPacketSocket.gd b/addons/kiripythonrpcwrapper/KiriPacketSocket/KiriPacketSocket.gd new file mode 100644 index 0000000..b441851 --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPacketSocket/KiriPacketSocket.gd @@ -0,0 +1,256 @@ +extends RefCounted +class_name KiriPacketSocket + +var _should_quit : bool = false +var _packet_buffer : KiriPacketBuffer = KiriPacketBuffer.new() +var _state : KiriSocketState = KiriSocketState.DISCONNECTED +var _outgoing_packet_queue : Array = [] + +var _state_lock : Mutex = Mutex.new() +var _worker_thread : Thread = null + +var _new_connections_to_server : Array = [] +var _error_string : String = "" + +# This class exists in __init__.py, ported as the original Python. Any +# functional changes to this should be reflected in that implementation as well. +class KiriPacketBuffer: + var _receive_buffer : PackedByteArray = [] + var _packet_buffer : Array = [] + + func _grab_complete_packets(): + while len(_receive_buffer) >= 4: + + var next_packet_size : int = \ + _receive_buffer[0] | \ + (_receive_buffer[1] << 8) | \ + (_receive_buffer[2] << 16) | \ + (_receive_buffer[3] << 24) + + if len(_receive_buffer) >= 4 + next_packet_size: + var next_packet = _receive_buffer.slice(4, 4 + next_packet_size) + assert(len(next_packet) == next_packet_size) + _receive_buffer = _receive_buffer.slice(4 + len(next_packet)) + _packet_buffer.append(next_packet) + + else: + + break + + func _have_complete_packet(): + _grab_complete_packets() + return len(_packet_buffer) > 0 + + func get_next_packet(): + if not _have_complete_packet(): + return null + return _packet_buffer.pop_front() + + func add_bytes(incoming_bytes : PackedByteArray): + _receive_buffer.append_array(incoming_bytes) + +enum KiriSocketState { + DISCONNECTED = 0, + CONNECTING = 1, + CONNECTED = 2, + SERVER_STARTING = 3, + SERVER_LISTENING = 4, + ERROR = 5 +} + +func _notification(what): + if what == NOTIFICATION_PREDELETE: + assert(not _worker_thread) + +func send_packet(packet_bytes : PackedByteArray): + assert(packet_bytes) + _state_lock.lock() + _outgoing_packet_queue.append(packet_bytes) + _state_lock.unlock() + +func get_next_packet(): + _state_lock.lock() + var ret = _packet_buffer.get_next_packet() + _state_lock.unlock() + return ret + +func get_next_server_connection(): + _state_lock.lock() + var ret = null + if len(_new_connections_to_server) > 0: + ret = _new_connections_to_server.pop_front() + _state_lock.unlock() + return ret + +func get_last_error(): + _state_lock.lock() + var ret = _error_string + _state_lock.unlock() + return ret + +func is_disconnected_or_error(): + _state_lock.lock() + var bad_states = [ + KiriSocketState.DISCONNECTED, + KiriSocketState.ERROR + ] + + var ret : bool = false + if _state in bad_states: + ret = true + + _state_lock.unlock() + + return ret + +func get_state(): + _state_lock.lock() + var ret = _state + _state_lock.unlock() + return ret + +func start_server(address): + + _set_state(KiriSocketState.SERVER_STARTING) + + assert(not _worker_thread) + _worker_thread = Thread.new() + _worker_thread.start(_server_thread_func.bind(address)) + +func start_client(address): + + _set_state(KiriSocketState.CONNECTING) + + assert(not _worker_thread) + + _worker_thread = Thread.new() + _worker_thread.start(_client_thread_func.bind(address)) + +func stop(): + + assert(_worker_thread) + _should_quit = true + _worker_thread.wait_to_finish() + _worker_thread = null + _should_quit = false + +func is_running(): + return not (_worker_thread == null) + +func _normal_communication_loop(sock : StreamPeer, address): + + while not _should_quit: + + if sock.poll() != OK: + break + + if sock.get_status() != StreamPeerTCP.STATUS_CONNECTED: + break + + # Get new data. + _state_lock.lock() + var available_bytes = sock.get_available_bytes() + if available_bytes > 0: + var incoming_bytes = sock.get_data(available_bytes) + _packet_buffer.add_bytes(PackedByteArray(incoming_bytes[1])) + if incoming_bytes[0] != OK: + break + _state_lock.unlock() + + # Send all packets from queue. + _state_lock.lock() + while len(self._outgoing_packet_queue): + var next_outgoing_packet = _outgoing_packet_queue.pop_front() + var len_to_send = len(next_outgoing_packet) + sock.put_u8((len_to_send & 0x000000ff) >> 0) + sock.put_u8((len_to_send & 0x0000ff00) >> 8) + sock.put_u8((len_to_send & 0x00ff0000) >> 16) + sock.put_u8((len_to_send & 0xff000000) >> 24) + sock.put_data(next_outgoing_packet) + _state_lock.unlock() + + OS.delay_usec(1) + +func _client_thread_func(address): + + var sock : StreamPeerTCP = StreamPeerTCP.new() + + # Connect to the server. + _set_state(KiriSocketState.CONNECTING) + var connect_err = sock.connect_to_host(address[0], address[1]) + + if connect_err == OK: + _set_state(KiriSocketState.CONNECTED) + + _normal_communication_loop(sock, address) + + # We are now disconnected. + _set_state(KiriSocketState.DISCONNECTED) + sock.disconnect_from_host() + + else: + _set_state(KiriSocketState.ERROR, "Connection failed") + +func _set_state(state : KiriSocketState, error_string=null): + _state_lock.lock() + _state = state + + if _state == KiriSocketState.ERROR: + assert(error_string) + _error_string = error_string + else: + assert(not error_string) + _error_string = "" + + _state_lock.unlock() + +func _server_to_client_thread_func(connection : StreamPeerTCP, address): + + _set_state(KiriSocketState.CONNECTED) + _normal_communication_loop(connection, address) + + # FIXME: Missing some error handling here due to exception differences + # between Python and GDScript. + + # Only switch to "disconnected" if we were most recently + # connected, otherwise we could mask an error. + if get_state() == KiriSocketState.CONNECTED: + _set_state(KiriSocketState.DISCONNECTED) + +func _server_thread_func(address): + + while not _should_quit: + + var sock : TCPServer = TCPServer.new() + + var listen_err = sock.listen(address[1], address[0]) + + if listen_err != OK: + + # FIXME: I wonder if we should do this in the main + # thread so we can get the exceptions back up to + # the start_server function and up from there. + _set_state(KiriSocketState.ERROR, "Could not listen on port.") + break + + _set_state(KiriSocketState.SERVER_LISTENING) + + while not _should_quit: + + if sock.is_connection_available(): + var connection : StreamPeerTCP = sock.take_connection() + var new_client : KiriPacketSocket = KiriPacketSocket.new() + new_client._start_client_connection_from_server(connection, address) + _state_lock.lock() + _new_connections_to_server.append(new_client) + _state_lock.unlock() + + OS.delay_usec(1) + +func _start_client_connection_from_server(connection : StreamPeerTCP, address): + + assert(not _worker_thread) + _worker_thread = Thread.new() + _worker_thread.start(_server_to_client_thread_func.bind(connection, address)) + + diff --git a/addons/kiripythonrpcwrapper/KiriPacketSocket/LICENSE.md b/addons/kiripythonrpcwrapper/KiriPacketSocket/LICENSE.md new file mode 100644 index 0000000..82ff637 --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPacketSocket/LICENSE.md @@ -0,0 +1,20 @@ +Copyright © 2024 Kiri Jolly + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +“Software”), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/addons/kiripythonrpcwrapper/KiriPacketSocket/README.md b/addons/kiripythonrpcwrapper/KiriPacketSocket/README.md new file mode 100644 index 0000000..745b51a --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPacketSocket/README.md @@ -0,0 +1,14 @@ +# KiriPacketSocket + +TODO - explanation + +## Python setup instructions and use + +TODO - setup and a simple tutorial +TODO - embed example script + +## Godot setup instructions and use + +TODO - setup and a simple tutorial +TODO - embed example project (remember to add .gdignore) + diff --git a/addons/kiripythonrpcwrapper/KiriPacketSocket/__init__.py b/addons/kiripythonrpcwrapper/KiriPacketSocket/__init__.py new file mode 100644 index 0000000..f276ba6 --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPacketSocket/__init__.py @@ -0,0 +1,418 @@ +#!/usr/bin/python3 + +# Copyright © 2024 Kiri Jolly + +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the “Software”), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import socket +import threading +import time +import sys +import enum + +class PacketSocket: + """Socket wrapper that encapsulates its own packet protocol and + manages its own threads. Polling-style interface. + + Packets are simply binary blobs with a little-endian 32-bit + unsigned integer size prepended to them. Input data will be split + into individual packets based on these. + + TCP/IP only. + + Main public API: + + - start_client(address) - Connect to an open port. + + - start_server(address) - Start a server with an open port. This + will start receiving connections, which much be accepted with + get_next_server_connection(). + + - get_next_server_connection() - Get the next incoming connection + (returns None if nothing is currently connecting, or a new + PacketSocket that can actually receive packets, otherwise). + Polling interface. + + - send_packet(b) - Sends a byte array (b) as a packet. This adds + it to a queue which is send in its own thread. + + - get_next_packet() - Gets the next complete incoming packet as a + byte array or None if no packet yet (polling interface). + + - get_last_error() - Gets the last error as a string. + + - is_disconnected_or_error() - True if we've had a problem (use + get_last_error() to see what the problem was). + + - stop() - Shutdown server or disconnect client (undoes + start_client/start_server). Also must be used on the + PacketSocket objects returned by get_next_server_connection(). + + Internal packet format: + + - size: 4-byte little-endian unsigned integer indicating the + number of bytes to follow for the next packet. + + - data: As many bytes of data that were specified in the 'size'. + + Note: 'size' does not include the space needed for the 'size' + value itself. + + Example packet (bytes): + [ 0x03, 0x00, 0x00, 0x00, 0x41, 0x42, 0x43 ] + + The first four bytes, [ 0x03, 0x00, 0x00, 0x00 ], indicate the + size of the data, in a little endian unsigned integer. In this + case that decodes to three. The next three bytes, [ 0x41, 0x42, + 0x43 ] are the packet data. In this case they represent the ASCII + string "ABC". + + To send this example packet, one would use send_packet(b'ABC'). + + On the receiving side, the return value of get_next_packet() would + be b'ABC'. + + This class exists in KiriPacketSocket.gd, ported to GDScript. Any + functional changes to this should be reflected in that + implementation as well. + """ + + class PacketBuffer: + """Receiving buffer for packets. Accumulates bytes until + complete packets are formed. + """ + + def __init__(self): + self._receive_buffer = b'' + self._packet_buffer = [] + + def _grab_complete_packets(self): + + while len(self._receive_buffer) >= 4: + + next_packet_size = int.from_bytes( + self._receive_buffer[0:4], + "little") + + if len(self._receive_buffer) >= 4 + next_packet_size: + + next_packet = self._receive_buffer[4 : 4 + next_packet_size] + self._receive_buffer = self._receive_buffer[4 + len(next_packet):] + self._packet_buffer.append(next_packet) + + else: + + break + + def _have_complete_packet(self): + self._grab_complete_packets() + return len(self._packet_buffer) > 0 + + def get_next_packet(self): + if not self._have_complete_packet(): + return None + return self._packet_buffer.pop(0) + + def add_bytes(self, incoming_bytes): + self._receive_buffer += incoming_bytes + + class SocketState(enum.Enum): + DISCONNECTED = 0 + CONNECTING = 1 + CONNECTED = 2 + SERVER_STARTING = 3 + SERVER_LISTENING = 4 + ERROR = 5 + + def __init__(self): + self._should_quit = False + self._packet_buffer = self.PacketBuffer() + self._state = self.SocketState.DISCONNECTED + self._outgoing_packet_queue = [] + + self._state_lock = threading.Lock() + self._worker_thread = None + + self._new_connections_to_server = [] + self._error_string = None + + def __del__(self): + # WE BETTER NOT HAVE ZOMBIE THREADS SITTING AROUND. + assert(not self._worker_thread) + + def send_packet(self, packet_bytes): + """Add a binary blob to the send queue.""" + assert(packet_bytes) + with self._state_lock: + + assert(packet_bytes) + self._outgoing_packet_queue.append(packet_bytes) + + def get_next_packet(self): + """Get a binary blob from the receive queue.""" + with self._state_lock: + ret = self._packet_buffer.get_next_packet() + + return ret + + def get_next_server_connection(self): + """For servers: Get the next incoming connection as a PacketSocket instance. + + Returns None if there are no incoming connections. + + """ + with self._state_lock: + ret = None + if len(self._new_connections_to_server): + ret = self._new_connections_to_server.pop(0) + return ret + + def get_last_error(self): + """Get the last error, as a string. (From the thrown exception.) + + Returns None if there has not been an error. + + """ + with self._state_lock: + return self._error_string + + def is_disconnected_or_error(self): + """Returns True if this socket has disconnected, or thrown an + error. (One way or another, the connection is gone and + resources may be cleaned up.) + + Note: May still have un-processed packets queued. + + """ + with self._state_lock: + + bad_states = [ + self.SocketState.DISCONNECTED, + self.SocketState.ERROR + ] + + if self._state in bad_states: + return True + + return False + + def get_state(self): + """Returns the current SocketState of this object.""" + + with self._state_lock: + return self._state + + def start_server(self, address): + """For servers: Start a listening server. (And start worker + thread.) + + Address is a tuple with a host IP (string) and a port number + (int). Use "0.0.0.0" to open on every interface. + + """ + + self._set_state(self.SocketState.SERVER_STARTING) + + assert(not self._worker_thread) + self._worker_thread = threading.Thread( + target=self._server_thread_func, + args=[address], + daemon=True) + + self._worker_thread.start() + + def start_client(self, address): + """For clients: Attempt to connect to a listening server. (And + start worker thread.) + + Address is a tuple with a host IP (string) and a port number + (int). + + """ + + self._set_state(self.SocketState.CONNECTING) + + assert(not self._worker_thread) + + self._worker_thread = threading.Thread( + target=self._client_thread_func, + args=[address], + daemon=True) + + self._worker_thread.start() + + def stop(self): + """Disconnect and shutdown the thread. + + For servers: Note that this does not disconnect PacketSocket + instances that were established from this server. + + """ + + assert(self._worker_thread) + self._should_quit = True + self._worker_thread.join() + self._worker_thread = None + self._should_quit = False + + def is_running(self): + return not (self._worker_thread == None) + + def _normal_communication_loop(self, sock, address): + """Shared communication loop between clients and servers.""" + + # Packet wranging timeout. Should be low so we can send and + # receive packets fast. + sock.settimeout(0.0001) + + while not self._should_quit: + + # Get new data. + try: + incoming_bytes = sock.recv(1024) + if not incoming_bytes: + break + with self._state_lock: + self._packet_buffer.add_bytes(incoming_bytes) + except TimeoutError: + pass + + # Send all packets from queue. + with self._state_lock: + + while len(self._outgoing_packet_queue): + next_outgoing_packet = self._outgoing_packet_queue.pop(0) + sock.send(len(next_outgoing_packet).to_bytes(4, "little")) + sock.send(next_outgoing_packet) + + def _client_thread_func(self, address): + """Client startup thread function. Attempts to establishes connection.""" + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + + try: + # Connect to the server. + self._set_state(self.SocketState.CONNECTING) + sock.connect(address) + + self._set_state(self.SocketState.CONNECTED) + + self._normal_communication_loop(sock, address) + + # We are now disconnected. + self._set_state(self.SocketState.DISCONNECTED) + sock.close() + + except ConnectionError as ex: + self._set_state(self.SocketState.ERROR, str(ex)) + + def _set_state(self, state, error_string=None): + with self._state_lock: + + self._state = state + + if self._state == self.SocketState.ERROR: + assert(error_string) + self._error_string = error_string + else: + assert(not error_string) + self._error_string = None + + + def _server_to_client_thread_func(self, connection, address): + """Server connection startup thread function. Initiated + internally from a server listening for connections. + + """ + + self._set_state(self.SocketState.CONNECTED) + + try: + self._normal_communication_loop(connection, address) + except ConnectionError as ex: + self._set_state(self.SocketState.ERROR, str(ex)) + + # Only switch to "disconnected" if we were most recently + # connected, otherwise we could mask an error. + if self.get_state() == self.SocketState.CONNECTED: + self._set_state(self.SocketState.DISCONNECTED) + + def _server_thread_func(self, address): + """Server thread function. Attempts to bind to an address and + listen for incoming connections, in a loop. + + """ + + while not self._should_quit: + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + + # Timeout for waiting on incoming connections. This + # can be "large". We aren't doing any message + # wrangling besides getting these in. Worst case we + # add latency to shutdown with this. + sock.settimeout(0.01) + + try: + + # FXIME: This seems to be for UDP ports? + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + sock.bind(address) + sock.listen() + + except Exception as ex: + + # FIXME: I wonder if we should do this in the main + # thread so we can get the exceptions back up to + # the start_server function and up from there. + self._set_state(self.SocketState.ERROR, str(ex)) + break + + + self._set_state(self.SocketState.SERVER_LISTENING) + + while not self._should_quit: + + try: + connection, address = sock.accept() + + new_client = PacketSocket() + new_client._start_client_connection_from_server(connection, address) + + with self._state_lock: + self._new_connections_to_server.append(new_client) + + except TimeoutError: + pass + + def _start_client_connection_from_server(self, connection, address): + """Entrypoint for PacketSocket instances created by servers + accepting connections. + + """ + + assert(not self._worker_thread) + self._worker_thread = threading.Thread( + target=self._server_to_client_thread_func, + args=(connection, address), + daemon=True) + self._worker_thread.start() diff --git a/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper.gd b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper.gd new file mode 100644 index 0000000..8f7739c --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper.gd @@ -0,0 +1,11 @@ +@tool +extends EditorPlugin + +func _enter_tree(): + # Initialization of the plugin goes here. + pass + + +func _exit_tree(): + # Clean-up of the plugin goes here. + pass diff --git a/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/__init__.py b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/__init__.py new file mode 100755 index 0000000..c872c21 --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/__init__.py @@ -0,0 +1,154 @@ +#!/usr/bin/python3 + +import importlib.util +import sys +import argparse +import time +import psutil +import json + +import KiriPacketSocket + +# Parse arguments +arg_parser = argparse.ArgumentParser( + prog="KiriPythonRPCWrapper", + description="Wrapper for Python modules to RPCs from Godot.", + epilog="") + +arg_parser.add_argument("--script", type=str, required=True) +arg_parser.add_argument("--port", type=int, required=True) +arg_parser.add_argument("--parent_pid", type=int, required=True) + +args = arg_parser.parse_args() + + +# module_path = "../KiriPacketSocket/__init__.py" +# module_name = "KiriPacketSocket" + +module_path = args.script +module_name = "" + +# Attempt to load the module. +module_spec = importlib.util.spec_from_file_location( + module_name, module_path) +module = importlib.util.module_from_spec(module_spec) +module_spec.loader.exec_module(module) + +# This will be all the functions we find in the module that don't +# start with "_". +known_entrypoints = {} + +# Scan the module for "public" functions. +for entrypoint in dir(module): + + # Skip anything starting with "_". Probably not meant to be + # exposed. + if entrypoint.startswith("_"): + continue + + attr = getattr(module, entrypoint) + + # if hasattr(attr, "__call__"): + if callable(attr): + known_entrypoints[entrypoint] = attr + +# Connect to server. +packet_socket = KiriPacketSocket.PacketSocket() +packet_socket.start_client(("127.0.0.1", args.port)) +while packet_socket.get_state() == packet_socket.SocketState.CONNECTING: + time.sleep(0.001) + +if packet_socket.get_state() != packet_socket.SocketState.CONNECTED: + packet_socket.stop() + raise Exception("Failed to connect to RPC host.") + +print("Starting packet processing.") + + + +def send_error_response(code, message, request_id): + ret_dict = { + "jsonrpc" : "2.0", + "error" : { + "code" : code, + "message" : message + }, + "id" : request_id + } + ret_dict_json = json.dumps(ret_dict) + packet_socket.send_packet(ret_dict_json.encode("utf-8")) + +def send_response(result, request_id): + try: + ret_dict = { + "jsonrpc" : "2.0", + "result" : ret, + "id" : request_id + } + ret_dict_json = json.dumps(ret_dict) + packet_socket.send_packet(ret_dict_json.encode("utf-8")) + except Exception as e: + send_error_response(-32603, "Error sending result: " + str(e), request_id) + +# Start processing packets. +while True: + + # Shutdown when we lose connection to host. + if packet_socket.get_state() != packet_socket.SocketState.CONNECTED: + packet_socket.stop() + raise Exception("Disconnected from RPC host.") + + # Watch parent PID so we can clean up when needed. + if not psutil.pid_exists(args.parent_pid): + packet_socket.stop() + raise Exception("RPC host process died") + + next_packet = packet_socket.get_next_packet() + while next_packet: + this_packet = next_packet + next_packet = packet_socket.get_next_packet() + + print("GOT PACKET: ", this_packet) + + # FIXME: Handle batches. + + # Parse the incoming dict. + try: + request_dict_json = this_packet.decode("utf-8") + request_dict = json.loads(request_dict_json) + except Exception as e: + send_error_response(-32700, "Error parsing packet: " + str(e), request_id) + continue + + # Make sure all the fields are there. + try: + method = request_dict["method"] + func_args = request_dict["params"] + request_id = request_dict["id"] + except Exception as e: + send_error_response(-32602, "Missing field: " + str(e), request_id) + continue + + # Make sure the method is something we scanned earlier. + try: + func = known_entrypoints[method] + except Exception as e: + send_error_response(-32601, "Method not found: " + str(e), request_id) + continue + + # Call the dang function. + try: + ret = func(*func_args) + except Exception as e: + send_error_response(-32603, "Call failed: " + str(e), request_id) + continue + + send_response(ret, request_id) + + time.sleep(0.0001) + + + + + + diff --git a/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/test_module/__init__.py b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/test_module/__init__.py new file mode 100644 index 0000000..ced1a1e --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/test_module/__init__.py @@ -0,0 +1,13 @@ +#!/usr/bin/python3 + +this_is_not_a_thing_to_call = 0 + +def func_to_call(asdf): + print("called with: ", asdf) + global this_is_not_a_thing_to_call + this_is_not_a_thing_to_call += 1 + return str(asdf) + "blah" + +def other_func_to_call(): + print("jksdmckjsdncjksncs") + diff --git a/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper_start.py b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper_start.py new file mode 100755 index 0000000..9880d27 --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper_start.py @@ -0,0 +1,10 @@ +#!/usr/bin/python3 + +import time + +try: + import KiriPythonRPCWrapper +except Exception as e: + print(e) + time.sleep(5) + diff --git a/addons/kiripythonrpcwrapper/KiriPythonWrapperInstance.gd b/addons/kiripythonrpcwrapper/KiriPythonWrapperInstance.gd new file mode 100644 index 0000000..08f8d8d --- /dev/null +++ b/addons/kiripythonrpcwrapper/KiriPythonWrapperInstance.gd @@ -0,0 +1,219 @@ +extends RefCounted +class_name KiriPythonWrapperInstance + +var external_process_pid = -1 + +var server_packet_socket : KiriPacketSocket = null +var communication_packet_socket : KiriPacketSocket = null + +var python_script_path : String = "" + +enum KiriPythonWrapperStatus { + STATUS_RUNNING, + STATUS_STOPPED +} + +func _init(python_file_path : String): + python_script_path = python_file_path + +func _get_python_executable(): + # FIXME: Adjust per-OS. Maybe test a few locations. + return "/usr/bin/python3" + +func _get_wrapper_script(): + # FIXME: Paths will be different for builds. + var script_path = self.get_script().get_path() + var script_dirname = script_path.get_base_dir() + return ProjectSettings.globalize_path( \ + script_dirname + "/KiriPythonRPCWrapper_start.py") + +func get_status(): + + if external_process_pid == -1: + return KiriPythonWrapperStatus.STATUS_STOPPED + + if not OS.is_process_running(external_process_pid): + return KiriPythonWrapperStatus.STATUS_STOPPED + + return KiriPythonWrapperStatus.STATUS_RUNNING + +func start_process(): + + # FIXME: Make sure we don't have one running. + + var open_port = 9500 + + assert(not server_packet_socket) + server_packet_socket = KiriPacketSocket.new() + while true: + server_packet_socket.start_server(["127.0.0.1", open_port]) + + # Wait for the server to start. + while server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_STARTING: + OS.delay_usec(1) + + # If we're successfully listening, then we found a port to use and we + # don't need to loop anymore. + if server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_LISTENING: + break + + # This port is busy. Try the next one. + server_packet_socket.stop() + open_port += 1 + + print("Port: ", open_port) + + var python_exe_path : String = _get_python_executable() + var wrapper_script_path : String = _get_wrapper_script() + + var startup_command : Array = [ + "xterm", "-e", + python_exe_path, + wrapper_script_path, + "--script", python_script_path, + "--port", open_port, + "--parent_pid", OS.get_process_id()] + + print("startup command: ", startup_command) + + external_process_pid = OS.create_process( + startup_command[0], startup_command.slice(1), true) + + print("external process: ", external_process_pid) + +func stop_process(): + + if external_process_pid != -1: + OS.kill(external_process_pid) + external_process_pid = -1 + + + # Clean up server and communication sockets. + if server_packet_socket: + server_packet_socket.stop() + server_packet_socket = null + + if communication_packet_socket: + communication_packet_socket.stop() + communication_packet_socket = null + + +class KiriPythonWrapperActiveRequest: + + enum KiriPythonWrapperActiveRequestState { + STATE_WAITING_TO_SEND, + STATE_SENT, + STATE_RESPONSE_RECEIVED + } + + var id : int + var method_name : String + var arguments : Variant # Dictionary or Array + var callback # Callable or null + var state : KiriPythonWrapperActiveRequestState + var response # Return value from the call + var error_response = "" + +var _active_request_queue = {} +var _request_counter = 0 + +func call_rpc_async(method : String, args : Variant, callback = null) -> int: + + assert((args is Dictionary) or (args is Array)) + assert((callback == null) or (callback is Callable)) + + var new_request = KiriPythonWrapperActiveRequest.new() + new_request.id = _request_counter + _request_counter += 1 + new_request.method_name = method + new_request.arguments = args + new_request.callback = callback + + _active_request_queue[new_request.id] = new_request + + return new_request.id + +func call_rpc_sync(method : String, args : Variant): + + # Kinda hacky. We're using arrays because we can change the contents. + # Without the array or something else mutable we'd just end up with the + # internal pointer pointing to different values without affecting these + # ones. + var done_array = [false] + var response_list = [] + var request_id = call_rpc_async(method, args, func(request_ob): + done_array[0] = true + response_list.append(request_ob.response) + ) + + # Wait (block) until we get a response. + while not done_array[0]: + + # Bail out if something happened to our instance or connection to it. + if communication_packet_socket: + if communication_packet_socket.is_disconnected_or_error(): + break + + poll() + OS.delay_usec(1) + + if len(response_list): + return response_list[0] + + return null + +func poll(): + + # Hand-off between listening socket and actual communications socket. + if server_packet_socket: + communication_packet_socket = server_packet_socket.get_next_server_connection() + if communication_packet_socket: + server_packet_socket.stop() + server_packet_socket = null + + if communication_packet_socket: + + # Send all waiting requests + for request_id in _active_request_queue: + var request : KiriPythonWrapperActiveRequest = _active_request_queue[request_id] + if request.state == request.KiriPythonWrapperActiveRequestState.STATE_WAITING_TO_SEND: + var request_dict = { + "jsonrpc": "2.0", + "method": request.method_name, + "params": request.arguments, + "id": request.id + } + var request_dict_json = JSON.stringify(request_dict) + communication_packet_socket.send_packet(request_dict_json.to_utf8_buffer()) + request.state = request.KiriPythonWrapperActiveRequestState.STATE_SENT + + # Check for responses. + var packet = communication_packet_socket.get_next_packet() + while packet != null: + var packet_dict = JSON.parse_string(packet.get_string_from_utf8()) + if packet_dict: + if packet_dict.has("id"): + var request_id = packet_dict["id"] + + # floats aren't even allowed in JSON RPC as an id. Probably + # meant it to be an int. + if request_id is float: + request_id = int(request_id) + + if _active_request_queue.has(request_id): + var request : KiriPythonWrapperActiveRequest = \ + _active_request_queue[request_id] + if "result" in packet_dict: + request.response = packet_dict["result"] + else: + request.error_response = "Couldn't find result on packet." + if request.callback: + request.callback.call(request) + + # Clean up request. + _active_request_queue.erase(request_id) + + packet = communication_packet_socket.get_next_packet() + + + diff --git a/addons/kiripythonrpcwrapper/plugin.cfg b/addons/kiripythonrpcwrapper/plugin.cfg new file mode 100644 index 0000000..21abded --- /dev/null +++ b/addons/kiripythonrpcwrapper/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="KiriPythonRPCWrapper" +description="" +author="Kiri" +version="" +script="KiriPythonRPCWrapper.gd" diff --git a/addons/kiripythonrpcwrapper/test_rpc.py b/addons/kiripythonrpcwrapper/test_rpc.py new file mode 100755 index 0000000..1668b24 --- /dev/null +++ b/addons/kiripythonrpcwrapper/test_rpc.py @@ -0,0 +1,28 @@ +#!/usr/bin/python3 + +import time + +import KiriPacketSocket + +ps = KiriPacketSocket.PacketSocket() +ps.start_server(("127.0.0.1", 9506)) + +connections = [] + +while True: + + psc = ps.get_next_server_connection() + if psc: + print("Got connection.") + connections.append(psc) + psc.send_packet(b'ABCDEFGHIJ') + + + for conn in connections: + p = conn.get_next_packet() + while p: + print(p) + conn.send_packet(p + b'1') + p = conn.get_next_packet() + + time.sleep(0.0001) diff --git a/control.tscn b/control.tscn new file mode 100644 index 0000000..15ad234 --- /dev/null +++ b/control.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3 uid="uid://bl7j8w8guq0ns"] + +[ext_resource type="Script" path="res://Control.gd" id="1_xu82u"] + +[node name="Control" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_xu82u") diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..b370ceb --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..20dbaf5 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dd4sp7xpjgqrp" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..13c483a --- /dev/null +++ b/project.godot @@ -0,0 +1,29 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="GodotJSONRPCTest" +config/features=PackedStringArray("4.2", "GL Compatibility") +run/max_fps=60 +config/icon="res://icon.svg" + +[display] + +window/vsync/vsync_mode=0 + +[editor_plugins] + +enabled=PackedStringArray("res://addons/kiripythonrpcwrapper/plugin.cfg") + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility"