diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..152aa1f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +## 0.4 - Refactoring, Bugfixes and adding new features + +### Features +* **Added method:** Added `Pyghthouse.wait` as a new feature to synchronize with the frame building. Commenly used to ensure building a frame with the previous call of `Pyghthouse.set_image` +* **Added method:** Added `Pyghthouse.keep_running` as a new feature to keep the main thread alive. Useable to let callback functions run until keyboard interrupt +* **Error handeling:** `PyghthouseCanvas.set_image` will now throw more useful errors upon invalid image object + +### Changes +* **start ... stop:** `Pyghthouse.stop` now does the same as `Pyghthouse.close`. For consistency, we recommend to use the start ... stop pattern for the Pyghthouse routine. +* **main thread check:** The pyghthouse routine will now stop when the main thread has died. To keep the pyghthouse routine running, use `Pyghthouse.keep_running` +* **Wait for start:** `Pyghthouse.start` will now wait until the start sequence is completed + +### Removed +* **Removed dependency:** numpy has been removed as dependency to simplify the image structure +* **Redundant method:** `Pyghthouse.connect` was only intended for internal use and is now combined in `Pyghthouse.start` +* **Redundant behaviour:** signal handler and corresponding method `Pyghthouse._handle_sigint` is now replaced by the main thread check in `PHThread` +* **Unsupported method:** + + removed `Pyghthouse.get_image_raw` + + removed `Pyghthouse.empty_image_raw` + +### Bugfixes +* **Keyboard interrupt:** keyboard interrupt should now stop the whole program instead of only the main thread +* **Missing warning:** `VerbosityLevel.ALL` now prints all messages, instead of only messages with number 200 +* **Error handeling:** upon error inside the library, the Pyghthouse routine will now close properly +* **Fixed image mutations:** added locks for critical sections in `PyghthouseCanvas` to prevent rare image mutations. +* **Fixed connection deadlock:** added timeout to avoid deadlocks upon unexpected connection behaviour + +### Refactored +* **Added documentation** +* **Changed data structure:** Changed data structure of `PyghthouseCanvas` from a 3D numpy array to a 3D python list +* **Better maintainability:** Changed overall code structure to allow easier access to single code pieces +* **Changed internal package structure:** + + moved `PHThread` to the new script `_thread.py` + + moved `PHMessageHandler` into the new script `handler.py` + + moved `REID` into the new script `data.py` and renamed from `REID` to `ReID` + + moved `VerbosityLevel` into the new script `data.py` \ No newline at end of file diff --git a/example.py b/example.py index e970dcf..a35a0df 100644 --- a/example.py +++ b/example.py @@ -1,66 +1,77 @@ ''' -This example should give a simple overview on how to use the Pyghthouse. +This example should give a simple overview on how to use Pyghthouse. A generel orientation of what you need: - Import of pyghthouse - Creating an instance of Pyghthouse -- start connection -- Sending images with either a given function or set_image(image) -- close connection (not needed but recommend) +- Start Pyghthouse routine +- Sending images with either a given callback function or by set_image +- Stop Pyghthouse routine (not needed but recommended) More examples can be found in the examples folder. - -Note that this skript handles Pyghthouse as a local module compared to -the skripts in examples; These handle Pyghthouse as an installed package. -Check examples/README.md for more informations. ''' -from pyghthouse import Pyghthouse -import pyghthouse.utils as utils -from examples.config import UNAME, TOKEN - -# Optional: This condition only executes if run as a skript. +# Optional: This condition only executes if run as a script. # Importing this program won't execute this block. if __name__ == '__main__': - # Create instance of Pyghthouse and start connection + from pyghthouse import Pyghthouse + import pyghthouse.utils as utils + from examples.config import UNAME, TOKEN + + + # Create instance of Pyghthouse and start Pyghthouse routine username = UNAME token = TOKEN p = Pyghthouse(username, token) p.start() - # the image is a 3 dimensional list, meaning a list with [[[r,g,b]]] entries - # create a black image + # The image object is a 3 dimensional list. Each index is accessed via [row][collum][rgb] + # Create a black image img = p.empty_image() pos_x = 10 pos_y = 5 - # color entries are in rgb + + # The used color is in the RGB format. We use a list where each index represents a color channel. + # Index 0 for red, 1 for green, 2 for blue. + # Each color channel has a size of 1 byte, so values from 0 to 255 can be used. color = [100, 124, 24] - - # sends the given image + + # Set the image with one colored pixel img[pos_y][pos_x] = color p.set_image(img) key = input("Enter 'n' for the next image, enter any other key to skip\n") if key.upper() == "N": - # set rgb color with a converted hsv color + + # Our library also have convertors for other color formats. + # Now we convert a hsv color to an rgb color color = utils.from_hsv(0.5, 1.0, 0.7) - # set all pixels to the current color - for x in range(28): - for y in range(14): + # Set the color of all pixels + for y in range(14): + for x in range(28): img[y][x] = color + p.set_image(img) - key = input("Enter 'n' for the next image, enter any other key to skip\n") + + key = input("Enter 'n' for the next animation, enter any other key to skip\n") if key.upper() == "N": + color = [255, 255, 255] - # create 3 white lines + # Let 3 white lines appear for x in range(28): for y in range(3,10,3): + img[y][x] = color - p.set_image(img) + + p.set_image(img) + # set_image overwrites the old image. So to prevent the loss of an image, + # we wait until the frame has been build + p.wait() - p.close() \ No newline at end of file + + p.stop() \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index 8459534..6f4f532 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,36 +9,94 @@ Some scripts have additional dependencies; Check `README.md` in the repository r If you set up config.py with your username and API token, you won't have to enter them every time you run a script. Be aware that API tokens are only valid for a few days. -##### Beware: -Python imports search for the modules in the current folder and in installed packages. So, if you haven't installed -pyghthouse as a package, you need to change the imports of the files in this folder to work. -In the repository root directory should be a file called `example.py`. This file shows the usage of imports of -pyghthouse without having it installed as a package. -Note: To use the imports the same way as `example.py`, your skript has to be in the same folder. -### Available functions -Here you can find a list of functions containing in this package. +## Ways of programming a pyghthouse script -`from_html(html_color)` -Converts an HTML color string like FF7F00 or #c0ffee to RGB -`from_hsv(h: float, s: float, v: float)` -Converts HSV (float values between 0 and 1) colors to RGB. +The Pyghthouse library has two intended ways of usage: -###### The class `Pyghthouse` with -`Pyghthouse(username: str, token: str, ...)` -Set up the `Pyghthouse` object. Needed arguments are `username` and `token`. Optional arguments and further explanation -can be found in the class definition (see `pyghthouse\ph.py`) +- Passing a callback function at creation with `Pyghthouse(..., callback=function)` or with the method `Pyghthouse.set_image_callback(callback)` -`Pyghthouse.empty_image()` -Creates a black image. -This function can also be called with an instance of the `Pyghthouse` object (*`.empty_image()`*) +- Setting the currently shown canvas with `Pyghthouse.set_image()` -`.set_image(image)` -Sends the given `image`. -`.close()` -Closes the connection. +### Programming with callback -*More functions can be found in `pyghthouse\ph.py`* \ No newline at end of file +The following examples uses callback functions: + +- `huecircle.py` +- `noisefill.py` +- `rainbow.py` +- `rgbfill.py` +- `rgbscan.py` +- `twopoints.py` + +In summary, we first need a callback function to create images when called. After that, we initialize the pyghthouse routine. + +When we now start the pyghthouse routine, images will be generated by the given callback function. Generated images are then send to the server. This will happen in an interval given by the frame_rate. + +The pyghthouse routine is now running asynchronous to the main script. + +**Important to notice** is, that the pyghthouse routine will end when the main script ends. To let a pyghthouse routine run forever (until error or keyboard interrupt), the `keep_running()` method can be used. + + +### Programming with set_image + +The following examples use `set_image`: + +- `movingdot.py` +- `whitefill.py` + +Compared to the callback function, `set_image` is a lot simpler. + +We again need to initialize and start the pyghthouse routine. After that, we only need to call `set_image(new_image)` to send images to the server. + +**Important to notice** is, that `set_image`does not wait until the image is send. This means that we can overwrite the currently set image by setting a new one without waiting for the send. So to ensure important frames to be send, we recommend to use the method `wait()`. + + + +## Pyghthouse package content + + +### The class `pyghthouse.Pyghthouse` with + +- `Pyghthouse(username: str, token: str, ...)`: + + Set up the `Pyghthouse` object. Needed arguments are `username` and `token`. Optional arguments, like `frame_rate` and further explanations can be found in the class definition (see `pyghthouse\ph.py`) + + +- `Pyghthouse.start()` + + Starts the pyghthouse routine and opens the websocket connection. + + +- `Pyghthouse.stop()` + + Stops the pyghthouse routine and closes the websocket connection. + + +- `Pyghthouse.empty_image()` + + A static method which creates a black image. + This function can also be called with an instance of the `Pyghthouse` object (*`.empty_image()`*) + + +- `Pyghthouse.set_image(image)` + + Set the Pyghthouse canvas to `image`. Every `1/frame_rate` seconds, the last image set is send by the pyghthouse routine. + + +*More functions can be found in `pyghthouse\ph.py`* + + +### Utils functions +Here are additional functions in the pyghthouse package. + +- `pyghthouse.utils.from_html(html_color)`: + + Converts an HTML color string like FF7F00 or #c0ffee to RGB + +- `pyghthouse.utils.from_hsv(h: float, s: float, v: float)`: + + Converts HSV (float values between 0 and 1) colors to RGB. \ No newline at end of file diff --git a/examples/huecircle.py b/examples/huecircle.py index f4a63d8..36c89f3 100644 --- a/examples/huecircle.py +++ b/examples/huecircle.py @@ -32,3 +32,4 @@ def callback(): p = Pyghthouse(UNAME, TOKEN, image_callback=callback) print("Starting... use CTRL+C to stop.") p.start() + p.keep_running() \ No newline at end of file diff --git a/examples/noisefill.py b/examples/noisefill.py index ced1be6..63cc06e 100644 --- a/examples/noisefill.py +++ b/examples/noisefill.py @@ -24,3 +24,4 @@ def image_gen(): p = Pyghthouse(UNAME, TOKEN, image_callback=g.__next__, frame_rate=60) print("Starting... use CTRL+C to stop.") p.start() + p.keep_running() diff --git a/examples/rainbow.py b/examples/rainbow.py index d59b6c5..8862bfe 100644 --- a/examples/rainbow.py +++ b/examples/rainbow.py @@ -4,9 +4,15 @@ def rainbow_generator(): + image = Pyghthouse.empty_image() while True: for i in range(180): - yield [from_hsv((i / 180 + j / (14 * 28)) % 1.0, 1.0, 1.0) for j in range(14 * 28)] + for x in range(28): + for y in range(14): + j = x + y*28 + image[y][x] = from_hsv((i / 180 + j / (14 * 28)) % 1.0, 1.0, 1.0) + yield image + rainbow = rainbow_generator() @@ -20,4 +26,4 @@ def callback(): p = Pyghthouse(UNAME, TOKEN, image_callback=callback) print("Starting... use CTRL+C to stop.") p.start() - + p.keep_running() \ No newline at end of file diff --git a/examples/rgbfill.py b/examples/rgbfill.py index 97f8d81..55d7a3f 100644 --- a/examples/rgbfill.py +++ b/examples/rgbfill.py @@ -8,15 +8,15 @@ def image_gen(): image = np.zeros((14, 28, 3)) yield image while True: - for x in range(14): - for y in range(28): + for y in range(14): + for x in range(28): for j in range(3): - image[x, y, j] = 255 + image[y, x, j] = 255 yield image - for y in range(28): - for x in range(14): + for x in range(28): + for y in range(14): for j in range(3): - image[x, y, j] = 0 + image[y, x, j] = 0 yield image @@ -26,3 +26,4 @@ def image_gen(): p = Pyghthouse(UNAME, TOKEN, image_callback=g.__next__, frame_rate=60) print("Starting... use CTRL+C to stop.") p.start() + p.keep_running() \ No newline at end of file diff --git a/examples/rgbscan.py b/examples/rgbscan.py index 28939fc..ef63a90 100644 --- a/examples/rgbscan.py +++ b/examples/rgbscan.py @@ -9,11 +9,11 @@ def image_gen(): yield image while True: for j in range(3): - for x in range(14): - for y in range(28): - image[x, y, j] = 255 + for y in range(14): + for x in range(28): + image[y, x, j] = 255 yield image - image[x, y, j] = 0 + image[y, x, j] = 0 g = image_gen() @@ -22,3 +22,4 @@ def image_gen(): p = Pyghthouse(UNAME, TOKEN, image_callback=g.__next__, frame_rate=60) print("Starting... use CTRL+C to stop.") p.start() + p.keep_running() \ No newline at end of file diff --git a/examples/twopoints.py b/examples/twopoints.py index ef446b0..72dd406 100644 --- a/examples/twopoints.py +++ b/examples/twopoints.py @@ -62,3 +62,4 @@ def callback(self): i = ImageMaker() p = Pyghthouse(UNAME, TOKEN, image_callback=i.callback, frame_rate=60) p.start() + p.keep_running() \ No newline at end of file diff --git a/examples/whitefill.py b/examples/whitefill.py new file mode 100644 index 0000000..0f41201 --- /dev/null +++ b/examples/whitefill.py @@ -0,0 +1,29 @@ +from pyghthouse import Pyghthouse, VerbosityLevel +from config import UNAME, TOKEN +from time import sleep + + +def main_loop(): + p = Pyghthouse(UNAME, TOKEN, frame_rate=60) + p.start() + + img = p.empty_image() + while True: + for y in range(14): + for x in range(28): + img[y][x] = (255, 255, 255) + p.set_image(img) + p.wait() + + sleep(1) + + for y in range(13, -1, -1): + for x in range(27, -1, -1): + img[y][x] = [0,0,0] + p.set_image(img) + # We skip frames here, but our frame_rate is high enough to hide it + sleep(0.01) + + +if __name__ == '__main__': + main_loop() \ No newline at end of file diff --git a/pyghthouse/__init__.py b/pyghthouse/__init__.py index 23aa611..e772ec1 100644 --- a/pyghthouse/__init__.py +++ b/pyghthouse/__init__.py @@ -1 +1,2 @@ -from pyghthouse.ph import Pyghthouse, VerbosityLevel +from pyghthouse.ph import Pyghthouse +from pyghthouse.connection.data import VerbosityLevel diff --git a/pyghthouse/_thread.py b/pyghthouse/_thread.py new file mode 100644 index 0000000..35a10fa --- /dev/null +++ b/pyghthouse/_thread.py @@ -0,0 +1,189 @@ +from threading import Thread, Event, main_thread +from time import sleep, time + +from .data.canvas import PyghthouseCanvas +from .connection.wsconnector import WSConnector, VerbosityLevel + +class PHThread(Thread): + """ + A Pyghthouse-Thread for the Pyghthouse routine. + + The Pyghthouse routine consists of the following phases: + - Connection Phase: Connects the client to the webserver. + - Main routine: Loop for sending frames to the webserver. + - Ending Phase: Closes the connection and cleanup threads. + + In the main routine, this thread will build and send frames from **canvas**. The time between each frame is + indicated by **send_interval**. + + Attributes + ---------- + send_interval : float + Time (in seconds) between each frame. + + canvas : PyghthouseCanvas + Each frame will send the currently stored image in **canvas**. + + image_callback : function() -> image, optional + Function to produce a new image each frame. + When *None* given, the image saved in **canvas** will be used instead. + Default is *None*. + + connected : Event + Event flag is set to *True* after successful connection to the server. + Frames will be frequently send in *True* state. + + ready : Event + Event flag is set to *True* in the send process of a frame. + This flag will always be *True* when connected is *True*, unless the event is unset. + Used for waiting operations (see method `Pyghthouse.wait()`). + + stop_event : Event + Indicates when the Pyghthouse routine should be stopped. + + error : Event + Event flag is set to *True* when an error occured inside the pyghthouse routine. + """ + + def __init__(self, send_interval, image_callback, canvas:PyghthouseCanvas, + username:str, token:str, address:str, verbosity:VerbosityLevel, ignore_ssl_cert:bool, timeout=2.5): + """ + Initialize thread for Pyghthouse routine. + + Parameters + ---------- + send_interval : float + Time (in seconds) between each frame. + + canvas : PyghthouseCanvas + Each frame will send the currently stored image in **canvas**. + + image_callback : function() -> image, optional + Function to produce a new image each frame. + When *None* given, the image saved in **canvas** will be used instead. + Default is *None*. + """ + super().__init__() + self.send_interval = send_interval + self.callback = image_callback + self.canvas = canvas + self.verbosity = verbosity + + self.connector = WSConnector(username, token, address, + verbosity, ignore_ssl_cert, timeout=timeout) + + self.connected = Event() + self.ready = Event() + self.stop_event = Event() + self.error = Event() + self.exception = None + + # Used to react to unexpected end of main + self.main_thread = main_thread() + + + + def stop(self): + """ + Ends Pyghthouse routine. + """ + self.stop_event.set() + + + def _is_stop(self): + """ + Ends Pyghthouse routine when **stop_event** is set or upon unexpected + end of main. + + Errors inside the routine also results into the stopping process. + """ + if self.stop_event.is_set(): + return True + + if self.connector.error.is_set(): + return True + + if not self.main_thread.is_alive(): + if self.verbosity == VerbosityLevel.ALL: + print("Main thread dead.") + + return True + + return False + + + def run(self): + """ + Starts Pyghthouse-Thread routine. This routine build and sends frames in the selected interval. + """ + if self.verbosity == VerbosityLevel.ALL: + print("Starting Pyghthouse routine.") + + self._connect() + + while not self._is_stop(): + + self._send_image() + + sleep_time = self.send_interval - (time() % self.send_interval) + sleep(sleep_time) + + if self.verbosity == VerbosityLevel.ALL: + print("Ending Pyghthouse routine.") + + self._disconnect() + + + def _send_image(self): + """ + Build and send current frame. + + Also sets the **ready** flag after successful frame creation. + """ + if self.callback is not None: + self._set_callback_image() + bytes_image = self.canvas.get_bytes_image() + + # Signal wait function in ph.py to continue + self.ready.set() + + self.connector.send(bytes_image) + + + def _set_callback_image(self): + """ + Set image from callback function. + + When an error occures, the connection will be closed and the exeption will be stored for further handeling in `ph.py` + """ + try: + image_from_callback = self.callback() + self.canvas.set_image(image_from_callback) + + except Exception as exception: + self.exception = exception + self.error.set() + + self.stop() + + + def _connect(self): + """ + Opens websocket connection. + + This function will also wait until the opening process has been finished + """ + self.connector.open() + self.connected.set() + + + def _disconnect(self): + """ + Closes the connection. + + Closing the connection also stops the websocket thread. So this function allows the Pyghthouse thread to exit + properly. + """ + self.connected.clear() + self.ready.set() + self.connector.close() \ No newline at end of file diff --git a/pyghthouse/connection/data.py b/pyghthouse/connection/data.py new file mode 100644 index 0000000..85b19b4 --- /dev/null +++ b/pyghthouse/connection/data.py @@ -0,0 +1,19 @@ +from enum import Enum + +class ReID: + def __init__(self): + self._next = 0 + + def __next__(self): + n = self._next + self._next += 1 + return n + + def __iter__(self): + return self + +class VerbosityLevel(Enum): + NONE = 0 + WARN_ONCE = 1 + WARN = 2 + ALL = 3 \ No newline at end of file diff --git a/pyghthouse/connection/handler.py b/pyghthouse/connection/handler.py new file mode 100644 index 0000000..acb9cb8 --- /dev/null +++ b/pyghthouse/connection/handler.py @@ -0,0 +1,35 @@ +from .data import VerbosityLevel + +# TODO: Rename? To WarningHandler? +class PHMessageHandler: + + def __init__(self, kwargs): + self.verbosity = kwargs["verbosity"] + self.warned_already = False + + def reset(self): + self.warned_already = False + + def handle(self, msg): + # TODO: Decide to print error to console and keep going or to close connection and stop pyghthouse routine + if self.verbosity == VerbosityLevel.ALL: + print(msg) + return + + match msg["RNUM"]: + case 200: + pass + case 401: + self.print_warning(msg, "Are Username and Token correct?") + case _: + self.print_warning(msg) + + def print_warning(self, msg, hint=""): + if self.verbosity == VerbosityLevel.WARN_ONCE and self.warned_already: + return + + print(f"Warning: {msg['RNUM']} {msg['RESPONSE']} {', '.join(msg['WARNINGS'])}") + if hint: + print(hint) + + self.warned_already = True diff --git a/pyghthouse/connection/wsconnector.py b/pyghthouse/connection/wsconnector.py index d451f0a..93d5518 100644 --- a/pyghthouse/connection/wsconnector.py +++ b/pyghthouse/connection/wsconnector.py @@ -1,78 +1,245 @@ -from threading import Thread, Lock -from websocket import WebSocketApp, setdefaulttimeout, ABNF +from threading import Thread, Event +from websocket import WebSocketApp, setdefaulttimeout, WebSocketConnectionClosedException from msgpack import packb, unpackb from ssl import CERT_NONE +from .data import VerbosityLevel, ReID +from .handler import PHMessageHandler class WSConnector: + """ + A connector to the Lighthouse API with websocket thread. - class REID: - def __init__(self): - self._next = 0 + Attributes + ---------- + connected : Event + Event flag is set to *True* when websocket thread is connected to the webserver and **send** can be used. + + error : Event + Event flag is set to *True* when error occured and connection is closed. + + The flag **error** has a higher priotiy than the **connected** flag. Meaning when **error** is set to *True*, the + connection is closed even when **connected** can be set to *True*. - def __next__(self): - n = self._next - self._next += 1 - return n + For further invastigation, check developer notes in `_on_error()`. + """ + + def __init__(self, username:str, token:str, address:str, + verbosity=VerbosityLevel.WARN_ONCE, ignore_ssl_cert:bool=False, + handler=PHMessageHandler, timeout=2.5): + """ + WSConnector initialization. - def __iter__(self): - return self - - def __init__(self, username: str, token: str, address: str, on_msg=None, ignore_ssl_cert=False): + Parameters + ---------- + username: str + Username + token: str + API-Token + address: str + Address of webserver. + verbosity: VerbosityLevel + Indicate level of displayed informations. + ignore_ssl_cert: bool + Ignores ssl certification when set to *True*. Default ssl certification is none, so no certificates from + the other side are required (or will be looked at if provided). + handler: PHMessageHandler + Handles the messages received from the network. + timeout: int or float + Set timeout of WebSocket connection. + """ + self.username = username self.token = token self.address = address - self.on_msg = on_msg - self.ws = None - self.lock = Lock() - self.reid = self.REID() - self.running = False - self.ignore_ssl_cert = ignore_ssl_cert - setdefaulttimeout(60) + self.reID = ReID() + self.verbosity = verbosity + + self.connected = Event() + self.error = Event() + self.exception = None + + kwargs = {"verbosity": self.verbosity} + handler = handler(kwargs) + self.handle_message = handler.handle + + self.ws = WebSocketApp(address, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close) + + kwargs = {"sslopt": {"cert_reqs": CERT_NONE}} + if ignore_ssl_cert: + kwargs = None + + self.set_timeout(timeout) + + self.thread = Thread(target=self.ws.run_forever, kwargs=kwargs) + + + def open(self): + """ + Opens websocket connection. + + Starts websocket thread which will open the websocket connection. + + After opening the websocket, the websocket thread sets the **connected** event flag to *True* and is ready to + send data. + + When an error occured upon opening, the **error** flag will be set to *True* and `_on_error()` will be called. + In this case, the connection will be closed again and the **connected** flag will be cleared to *False* again. + """ + if self.verbosity == VerbosityLevel.ALL: + print("Opening websocket connection.") + + self.thread.start() + + if not self.connected.wait(self.timeout + 0.2): + raise RuntimeError("Unexpected behaviour. Reached wait timeout before socket timeout.") + + if self.error.is_set(): + self.connected.clear() + + if self.verbosity == VerbosityLevel.ALL: + print(f"Error upon connecting to {self.address}") + + def send(self, data): - with self.lock: - self.ws.send(packb(self.construct_package(data), use_bin_type=True), opcode=ABNF.OPCODE_BINARY) - - def start(self): - self.stop() - self.ws = WebSocketApp(self.address, - on_message=None if self.on_msg is None else self._handle_msg, - on_open=self._ready, on_error=self._fail) - self.lock.acquire() - kwargs = {"sslopt": {"cert_reqs": CERT_NONE}} if self.ignore_ssl_cert else None - Thread(target=self.ws.run_forever, kwargs=kwargs).start() - self.lock.acquire() # wait for connection to be established - self.lock.release() - - def _fail(self, ws, err): - self.lock.release() - raise err - - def stop(self): - if self.ws is not None: - with self.lock: - print("Closing the connection.") - self.running = False - self.ws.close() - self.ws = None - - def _ready(self, ws): - print(f"Connected to {self.address}.") - self.running = True - self.lock.release() - - def _handle_msg(self, ws, msg): - if isinstance(msg, bytes): - msg = unpackb(msg) - self.on_msg(msg) + """ + Sends data via websocket connection. + + Raises a *WebSocketConnectionClosedException* when no connection is present. + """ + if self.error.is_set(): + return + + if self.connected.is_set(): + try: + if self.verbosity == VerbosityLevel.ALL: + print("Sending frame.") + + self.ws.send_bytes(self.construct_package(data)) + + except Exception as e: + if self.verbosity == VerbosityLevel.ALL: + print("Failed send process.") + + self.close() + + else: + self.exception = WebSocketConnectionClosedException("Cannot send frames. Connection is closed.") + self.error.set() + + + + def close(self): + """ + Closes websocket connection. + + This function can still be used when no connection is present. In this case, nothing will happen. + """ + if self.verbosity == VerbosityLevel.ALL: + print("Closing connection.") + + self.connected.clear() + self.ws.close() + def construct_package(self, payload_data): - return { - 'REID': next(self.reid), + data = { + 'REID': next(self.reID), 'AUTH': {'USER': self.username, 'TOKEN': self.token}, 'VERB': 'PUT', 'PATH': ['user', self.username, 'model'], 'META': {}, 'PAYL': payload_data } + return packb(data, use_bin_type=True) + + + def set_timeout(self, timeout=10): + self.timeout = 2.5 + if timeout > 0: + self.timeout = timeout + setdefaulttimeout(self.timeout) + + + ## Functions used by the websocket thread ## + + def _on_open(self, ws: WebSocketApp): + """ + Called directly after websocket has been successfully opened. + """ + if self.verbosity == VerbosityLevel.ALL: + print(f"Connected to {self.address}") + + self.connected.set() + + + def _on_message(self, ws: WebSocketApp, message): + if self.verbosity == VerbosityLevel.ALL: + print("Received message:") + + if isinstance(message, bytes): + message = unpackb(message) + self.handle_message(message) + + + def _on_close(self, ws: WebSocketApp, close_status_code, close_msg): + """ + Called after websocket connection fully closed. + + ---------------- + Developer notes: + + When the websocket thread has been started, `_on_close()` will always be called, even when an error occured upon + opening the connection. This is a result of the `teardown()` function in WebSocketApp. + + So its possible that `_on_close()` will be called even when `_on_open()` hasn't been called yet. + + For further invastigation, check the developer notes in `_on_error()`. + """ + if self.verbosity == VerbosityLevel.ALL: + print("Connection closed.") + + self.connected.clear() + + + def _on_error(self, ws: WebSocketApp, err: Exception): + """ + Further handeling of errors outside of WebSocketApp. + + This function will save the **exception** and set the flag **error** to *True*. + + This function also sets the flag **connected** to *True* to avoid blocking of **open** when an error occured + upon opening the connection. + + ---------------- + Developer notes: + + This method is used by WebSocketApp as callback function. The intend is to signal that an error occured in + WebSocketApp and to allow further error handeling outside of WebSocketApp. + + Because we use `run_forever()` without a parameter for `reconnect()`, we use the standard of *0* from the + websocket-client library. This will always force a teardown of the connection without an attempt to reconnect + upon error. + + A teardown of the connection will also call the `_on_close()` callback function, even when `_on_error()` + has been the called. `_on_close()` will even be called when an error occured on the attempt to open the + websocket. + Meaning, `_on_close()` can be called even when `_on_open()` hasn't been called yet. + + Do not raise an exception here! + -------------------------------- + This function will be called from the websocket thread. Raising an exception here stops the teardown process + and could result into unexpected behaviour. + """ + + if self.verbosity == VerbosityLevel.ALL: + print(err) + + self.exception = err + self.error.set() + self.connected.set() \ No newline at end of file diff --git a/pyghthouse/data/canvas.py b/pyghthouse/data/canvas.py index ee25d7a..700aafe 100644 --- a/pyghthouse/data/canvas.py +++ b/pyghthouse/data/canvas.py @@ -1,26 +1,139 @@ -from typing import Union, Iterable -import numpy as np - +from threading import Lock +from numbers import Number class PyghthouseCanvas: - - VALID_IMAGE_TYPE = Union[np.ndarray, Iterable, int] + IMAGE_SHAPE = (14, 28, 3) - + def __init__(self, initial_image=None): - self.image = np.zeros(self.IMAGE_SHAPE, dtype=np.ubyte) - if initial_image is None: - self.image[:, :, 0] = 255 - else: + + self.lock = Lock() + self.image = [[[0 for k in range(self.IMAGE_SHAPE[2])] for j in range(self.IMAGE_SHAPE[1])] for i in range(self.IMAGE_SHAPE[0])] + + if initial_image is not None: self.set_image(initial_image) - def set_image(self, new_image: VALID_IMAGE_TYPE) -> np.array: - try: - self.image[:] = np.asarray(new_image).reshape(self.IMAGE_SHAPE) - except ValueError as e: - raise ValueError(f"{e}. Most likely, your image does not have the correct dimensions.") from None + + def set_image(self, new_image: list) -> True: + """ + Set the canvas to the **new_image**. + + Throws exeptions when an invalid image object has been given. + + This function is thread-safe. + """ + # Catch invalid image objects + self._check_size(new_image) + self._check_cells(new_image) + self._check_values(new_image) + + # Setting the image + with self.lock: + + for y in range(self.IMAGE_SHAPE[0]): + for x in range(self.IMAGE_SHAPE[1]): + for rgb in range(self.IMAGE_SHAPE[2]): + + self.image[y][x][rgb] = int(new_image[y][x][rgb]) return self.image + + + def get_bytes_image(self): + """ + Returns the currently saved image in a bytearray. + + This function is thread-safe. + """ + image_bytes = b'' + + with self.lock: + + for y in range(self.IMAGE_SHAPE[0]): + for x in range(self.IMAGE_SHAPE[1]): + + image_bytes += bytes(self.image[y][x]) + + return image_bytes + + + def copy_image(self): + """ + Returns a copy of the currently saved image. + + This function is thread-safe. + """ + image_copy = [[[0 for k in range(self.IMAGE_SHAPE[2])] for j in range(self.IMAGE_SHAPE[1])] for i in range(self.IMAGE_SHAPE[0])] + + with self.lock: + + for y in range(self.IMAGE_SHAPE[0]): + for x in range(self.IMAGE_SHAPE[1]): + for rgb in range(self.IMAGE_SHAPE[2]): + + image_copy[y][x][rgb] = self.image[y][x][rgb] + + return image_copy + + + ## Internal error checking functions ## + + def _check_size(self, other: list): + """ + Check if we received a 3-dimensional list with the correct sizes. + """ + try: + other_size = (len(other), len(other[0]), len(other[0][0])) + + # Catch objects like [] or 0 + except (IndexError, TypeError): + raise TypeError(f"Received object with missing dimensions. Require a 3-dimensional list ") from None + + # Raise ValueError on wrong dimension size + if self.IMAGE_SHAPE != other_size: + raise ValueError(f"The image does not have the correct dimension sizes. Dimensions should be {self.IMAGE_SHAPE} as (row, column, rgb), but received {other_size}") + + + + def _check_cells(self, other: list): + """ + Check for the existence of all expected cells in our image object. Also catches if we have too many cells. + + We need this additional check because `_check_size()` only ensures that we received a 3-dimensional list. + """ + # Catch objects like [[[0,1,2],[0,1,2,3,4,5],[1]],1,...] + for y in range(self.IMAGE_SHAPE[0]): + try: + + if len(other[y]) != self.IMAGE_SHAPE[1]: + raise IndexError(f"column-list size should be {self.IMAGE_SHAPE[1]} but received {len(other[y])} at position [{y}]") + + except (TypeError): + raise TypeError(f"Require type 'list' at [{y}], but received object of type '{type(other[y]).__name__}'") from None + + for x in range(self.IMAGE_SHAPE[1]): + try: + + if len(other[y][x]) != self.IMAGE_SHAPE[2]: + raise IndexError(f"RGB-list size should be {self.IMAGE_SHAPE[2]} but received {len(other[y][x])} at position [{y}][{x}]") + + except (TypeError): + raise TypeError(f"Require type 'list' at [{y}][{x}], but received object of type '{type(other[y][x]).__name__}'") from None + + - def get_image_bytes(self): - return self.image.tobytes() + def _check_values(self, other: list): + """ + Check if all cells have valid numbers. + """ + for y in range(self.IMAGE_SHAPE[0]): + for x in range(self.IMAGE_SHAPE[1]): + for rgb in range(self.IMAGE_SHAPE[2]): + + value = other[y][x][rgb] + + if not isinstance(value, Number): + raise TypeError(f"Wrong type at ({y},{x},{rgb}). Type should be a Number, like 'int', but received '{type(value).__name__}'") + + if int(value) < 0 or int(value) > 255: + raise ValueError(f"Received value {value} at ({y},{x},{rgb}) is out of range. Value should be a number between 0 <= value <= 255") \ No newline at end of file diff --git a/pyghthouse/ph.py b/pyghthouse/ph.py index 62f92ea..4826ae0 100644 --- a/pyghthouse/ph.py +++ b/pyghthouse/ph.py @@ -1,21 +1,11 @@ -from enum import Enum -from time import time, sleep -from threading import Thread, Event, Lock from signal import signal, SIGINT +from time import sleep -import numpy as np - -from pyghthouse.data.canvas import PyghthouseCanvas -from pyghthouse.connection.wsconnector import WSConnector - - -class VerbosityLevel(Enum): - NONE = 0 - WARN_ONCE = 1 - WARN = 2 - ALL = 3 - +from .data.canvas import PyghthouseCanvas +from ._thread import PHThread +from .connection.wsconnector import VerbosityLevel +# TODO: Adjust example for more clarity class Pyghthouse: """ A Python Lighthouse adapter. @@ -34,23 +24,24 @@ class Pyghthouse: token: str A valid API token belonging to the user name. This is *not* your password. To obtain a token, login to - lighthouse.uni-kiel.de, open the top-left rollover menu and click "API-Token anzeigen". + lighthouse.uni-kiel.de, open the rollover menu "API Token" on the right side and click "Reveal Token" or use + the copy button next to it. address: str (optional, default: "wss://lighthouse.uni-kiel.de/websocket") URI of the WebSocket endpoint. You should not need to change this. frame_rate: float (optional; 0 < frame_rate <= 60, default: 30) - Rate in 1/sec at which frames (images) are automatically sent to the lighthouse server. Also determines how - often the image_callback function is called. + Rate in 1/sec at which frames (images) are automatically sent to the lighthouse server. Also determines the + rate of how often the image_callback function is called. image_callback: function (optional) - A function that takes no arguments and generates a valid lighthouse image (cf Image Format). If set, this - function is called before an image is sent and is used to determine said image. + Takes a function with no arguments and generates a valid lighthouse image. If set, this function is called + to generate an image for sending each frame. The function is guaranteed to be called frame_rate times each second *unless* its execution takes longer than 1/frame_rate seconds, in which case execution will be slowed down accordingly. verbosity: pyghthouse.VerbosityLevel (optional, default: pyghthouse.VerbosityLevel.WARN_ONCE) - How many reply messages from the server are printed to the console. + How many log messages and reply messages from the server are printed to the console. Options: pyghthouse.VerbosityLevel.NONE: All messages are suppressed. @@ -59,7 +50,7 @@ class Pyghthouse: pyghthouse.VerbosityLevel.WARN: Print all warnings. pyghthouse.VerbosityLevel.ALL: - Print all messages. + Print all log and reply messages. Image Format ------------ @@ -75,8 +66,8 @@ class Pyghthouse: Each color channel has a depth of 8 bits, i.e. is represented by a number between 0 and 255, inclusively. For instance, [255, 127, 0] is 100% red, 50% green and 0% blue, a.k.a. orange. - Images can be either flat or nested lists, as long as they have 14*28*3=1176 elements overall. The - Pyghthouse.empty_image() method returns a completely black image in the nested format, i.e. + Images are nested lists with 14*28*3=1176 elements overall. The Pyghthouse.empty_image() method returns a + completely black image in the nested format, i.e. [ [ [0, 0, 0,], @@ -91,166 +82,215 @@ class Pyghthouse: ] ] + Example + ------------ The following example creates a Pyghthouse and sets the 10th window of the 11th floor to orange. >>> from pyghthouse import Pyghthouse >>> p = Pyghthouse("YourUsername", "YourToken") - >>> p.start() # not necessary to set image, but necessary for sending. + >>> p.start() # necessary to set image and for sending. >>> img = Pyghthouse.empty_image() >>> img[3][9] = [255, 127, 0] >>> p.set_image(img) - - Full Example - ------------ - The following program renders a white dot that can be moved by up, down, left and right by typing W, S, A and D, - respectively (and pressing ENTER). - - >>> from pyghthouse import Pyghthouse, VerbosityLevel - >>> UNAME = "YourUsername" - >>> TOKEN = "YourToken" - >>> - >>> def clip(val, min_val, max_val): - >>> if val < min_val: - >>> return min_val - >>> if val > max_val: - >>> return max_val - >>> return val - >>> - >>> x = 0 - >>> y = 0 - >>> p = Pyghthouse(UNAME, TOKEN, verbosity=VerbosityLevel.NONE) - >>> p.start() - >>> while True: - >>> img = p.empty_image() - >>> img[y][x] = [255, 255, 255] - >>> p.set_image(img) - >>> s = input() - >>> for c in s.upper(): - >>> if c == 'A': - >>> x -= 1 - >>> elif c == 'D': - >>> x += 1 - >>> elif c == 'W': - >>> y -= 1 - >>> elif c == 'S': - >>> y += 1 - >>> x = clip(x, 0, 27) - >>> y = clip(y, 0, 13) + >>> p.wait() # ensures sending of the last image set by *set_image* There are more code examples in the git repository (https://github.com/ProjectLighthouseCAU/pyghthouse/tree/master/examples). """ - class PHMessageHandler: + def __init__(self, username: str, token: str, address: str = "wss://lighthouse.uni-kiel.de/websocket", + frame_rate: float = 30.0, image_callback=None, verbosity=VerbosityLevel.WARN_ONCE, + ignore_ssl_cert=False): + + self.canvas = PyghthouseCanvas() + + """ + The timeout of the lamp controllers is 5 seconds, so to prevent unexpected behaviour of the light + installation, our libraries timeout needs to be faster than the timeout of our lamp controller. + To be more precise, we need to be faster than: + (socket) timeout + send_interval + network delay < 5 + + The socket timeout consists of the full send process from the libraries websocket thread to our model server + beacon. So for the network delay, we only need to consider the streaming of the model inside the Lighthouse + infrastructure. + We can approximate this delay to a range from 0.1 to 0.5 seconds, + depending on the servers load. + + With a frame rate of 0.5, we get a send_interval of 2 seconds. + + So we get a total worst-case of: + (socket) timeout + send_interval + network delay = total delay + 2.5 + 2.0 + 0.5 ~ 5.0 + """ + if frame_rate <= 0.5 or frame_rate > 60.0: + raise ValueError("Frame rate must be greater than 0.5 and at most 60.") + + self.send_interval = 1.0 / frame_rate + self.timeout = 2.5 + + self.ph_thread = PHThread(self.send_interval, image_callback, self.canvas, + username, token, address, verbosity, ignore_ssl_cert, self.timeout) + - def __init__(self, verbosity=VerbosityLevel.WARN_ONCE): - self.verbosity = verbosity - self.warned_already = False + def start(self): + """ + Starts Pyghthouse routine. + + The pyghthouse routine can only be started once. A RuntimeError will be raised when trying to start an already + running instance of pyghthouse, causing the running instance to be stopped. + """ + if not self.ph_thread.connected.is_set(): + self.ph_thread.start() + + # Wait is not optional due to error checking of other functions + if not self.ph_thread.connected.wait(self.timeout + 0.3): + raise RuntimeError("Unexpected behaviour. Reached wait timeout before socket timeout.") + + else: + self.stop() + raise RuntimeError("Pyghthouse can only be started once.") - def reset(self): - self.warned_already = False - def handle(self, msg): - if msg['RNUM'] == 200: - if self.verbosity == VerbosityLevel.ALL: - print(msg) - elif self.verbosity == VerbosityLevel.WARN: - self.print_warning(msg) - elif self.verbosity == VerbosityLevel.WARN_ONCE and not self.warned_already: - self.print_warning(msg) - self.warned_already = True + def set_image(self, image): + """ + Sets pyghthouse canvas to a new image. - @staticmethod - def print_warning(msg): - print(f"Warning: {msg['RNUM']} {msg['RESPONSE']} {', '.join(msg['WARNINGS'])}") + This function overwrites the old image. Only the newest image will be converted to a frame by the pyghthouse + routine. To prevent the loss of an image, use **wait()** after **set_image()** call. - class PHThread(Thread): + Raises a RuntimeError upon trying to set an image when no pyghthouse routine is running. - def __init__(self, parent): - super().__init__() - self.parent = parent - self._stop_event = Event() + :param image: A 3D array where every entry is accessed via image[y][x][rgb]. + The dimension sizes are 14x28x3, meaning the last entry should be accessed with image[13][27][2]. + RGB entries only allow values in a range of 0 to 255 (one byte). + """ + if not self._routine_is_running(): + raise RuntimeError("Cannot set an image before Pyghthouse has started.") + + self.canvas.set_image(image) - def stop(self): - self._stop_event.set() - def stopped(self): - return self._stop_event.is_set() + def wait(self): + """ + Wait for finalization of the current frame. - def run(self): - while not self.stopped(): - with self.parent.config_lock: - sleep_time = self.parent.send_interval - (time() % self.parent.send_interval) - sleep(sleep_time) - if self.parent.image_callback is not None: - image_from_callback = self.parent.image_callback() - self.parent.set_image(image_from_callback) - self.parent.connector.send(self.parent.canvas.get_image_bytes()) + This function blocks the called thread until the current frame has been constructed. - def __init__(self, username: str, token: str, address: str = "wss://lighthouse.uni-kiel.de/websocket", - frame_rate: float = 30.0, image_callback=None, verbosity=VerbosityLevel.WARN_ONCE, - ignore_ssl_cert=False): - if frame_rate > 60.0 or frame_rate <= 0: - raise ValueError("Frame rate must be greater than 0 and at most 60.") - self.username = username - self.token = token - self.address = address - self.send_interval = 1.0 / frame_rate - self.image_callback = image_callback - self.canvas = PyghthouseCanvas() - self.msg_handler = self.PHMessageHandler(verbosity) - self.connector = WSConnector(username, token, address, on_msg=self.msg_handler.handle, - ignore_ssl_cert=ignore_ssl_cert) - self.config_lock = Lock() - self.ph_thread = None - signal(SIGINT, self._handle_sigint) + Recommended to prevent skipping/losing of important frames. - def connect(self): - self.connector.start() + **set_image()** sets the image as fast as possible. On the other hand, the pyghthouse routine creates a frame + every 1/frame_rate seconds with the last image set. + This will result into skipping/losing images, when we create our images faster than the frame rate. + To prevent the loss of an image, we use **wait()** to wait until the current frame has been finalized, so we + can guarantee that the last image set will be send. + + **Do not use for fast interactive games** because waiting can result into delayed or ignored inputs! + For fast interactive games it is recommended to set the image as soon as an input is received. + """ + if not self._routine_is_running(): + raise RuntimeError("Cannot wait without a Pyghthouse routine running.") + + self.ph_thread.ready.clear() + + if not self.ph_thread.ready.wait(self.timeout + self.send_interval + 0.2): + raise RuntimeError("Unexpected behaviour. Reached wait timeout before socket timeout.") + + + def keep_running(self): + """ + Keeps the main thread alive. + + This function blocks the main thread. This will keep the pyghthouse routine running. + + Recommended for the use of callback functions which should run until error or keyboard interrupt. + """ + while self._routine_is_running(): + self.wait() - def start(self): - if not self.connector.running: - self.connect() - self.stop() - self.msg_handler.reset() - self.ph_thread = self.PHThread(self) - self.ph_thread.start() def stop(self): - if self.ph_thread is not None: + """ + Stops Pyghthouse routine. + + Stops the Pyghthouse routine and closes the websocket connection. All threads used by Pyghthouse will be + stopped in the process. This process can take more time with lower frame rate. + + When Pyghthouse isn't running, no changes will be made. + """ + if self.ph_thread.connected.is_set(): self.ph_thread.stop() - self.ph_thread.join() - def close(self): - self.stop() - self.connector.stop() - def set_image(self, image): - with self.connector.lock: - self.canvas.set_image(image) + @staticmethod + def empty_image(): + """ + Returns an empty image. + + An empty image is a fully black image, meaning every RGB value is set to 0. + """ + return [[[0 for k in range(3)] for j in range(28)] for i in range(14)] + def get_image(self): - return self.get_image_raw().tolist() + """ + Returns a copy of the current canvas image. + """ + return self.canvas.copy_image() - def get_image_raw(self): - with self.connector.lock: - return self.canvas.image + + def set_image_callback(self, image_callback=None): + """ + Sets a new callback function for image creation. - @staticmethod - def empty_image_raw(): - return np.zeros((14, 28, 3)) + This function is async to the pyghthouse routine, so non-deterministic behaviour is possible. + + To prevent image loss, it is recommended to synchronize with the pyghthouse routine by using **wait()** for x + times where x is the amount of images send before calling this function. + + When the image_callback is set to *None*, the Pyghthouse routine will stop using the callback function for + image generation. + """ + self.ph_thread.callback = image_callback + + + def _routine_is_running(self): + # Check for errors + if self.ph_thread.error.is_set(): + raise self.ph_thread.exception + + if self.ph_thread.connector.error.is_set(): + raise self.ph_thread.connector.exception + + # Check if Pyghthouse routine is running + return self.ph_thread.connected.is_set() + + + ## Support of old features ## + + def close(self): + """ + Same as **stop()**. + + Stops Pyghthouse routine. + + Stops the Pyghthouse routine and closes the websocket connection. All threads used by Pyghthouse will be + stopped in the process. This process can take more time with lower frame rate. + + When Pyghthouse isn't running, no changes will be made. + """ + self.stop() - @staticmethod - def empty_image(): - return Pyghthouse.empty_image_raw().tolist() - def set_image_callback(self, image_callback): - with self.config_lock: - self.image_callback = image_callback + def set_frame_rate(self, frame_rate:float): + """ + Set frame_rate to a new value. - def set_frame_rate(self, frame_rate): - with self.config_lock: - self.send_interval = 1.0 / frame_rate + The frame_rate must be greater than 0.5 and at most 60. - def _handle_sigint(self, sig, frame): - self.close() - raise SystemExit(0) + This function executes asynchronous to the pyghthouse routine. It's not possible to guarantee a deterministic + behaviour of this function. + """ + if frame_rate <= 0.5 or frame_rate > 60.0: + self.stop() + raise ValueError("frame rate must be greater than 0.5 and at most 60.") + + self.ph_thread.send_interval = 1.0 / frame_rate \ No newline at end of file diff --git a/setup.py b/setup.py index cd5f6c4..1ac301f 100644 --- a/setup.py +++ b/setup.py @@ -5,17 +5,16 @@ setup( name='pyghthouse', - version='0.3.0', + version='0.4.0', packages=find_packages(where='.'), - url='https://github.com/Musicted/pyghthouse', + url='https://github.com/ProjectLighthouseCAU/pyghthouse', license='MIT', - author='Gavin Lüdemann', - author_email='gavin.luedemann@gmail.com', + author='Gavin Lüdemann, Nico Penning', + author_email='gavin.luedemann@gmail.com, nico.penning1@gmail.com', description='Python Lighthouse adapter', long_description=long_description, long_description_content_type="text/markdown", install_requires=[ - 'numpy >= 1.23', 'websocket-client >= 1.6, < 2', 'msgpack >= 1.0, < 2', ],