Repository: tronbyt/server
Pull request #216: Use the app output returned by libpixlet
Author: login: IngmarStein
Created: 2025-04-14T16:06:28Z | Merged: 2025-04-15T08:03:18Z

Requires https://github.com/tronbyt/pixlet/pull/57 to be submitted and deployed first.

See https://github.com/tronbyt/server/issues/138


Diff:
diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh
index 95de17ef..a31b5570 100755
--- a/.devcontainer/postCreateCommand.sh
+++ b/.devcontainer/postCreateCommand.sh
@@ -3,7 +3,7 @@
 pip3 install --user -r requirements.txt pytest
 mypy --install-types --non-interactive --ignore-missing-imports --exclude system-apps .
 
-PIXLET_VERSION=v0.41.0
+PIXLET_VERSION=v0.42.0
 curl -LO "https://github.com/tronbyt/pixlet/releases/download/${PIXLET_VERSION}/pixlet_${PIXLET_VERSION}_linux_amd64.tar.gz"
 sudo tar -C /usr/local/bin -xvf "pixlet_${PIXLET_VERSION}_linux_amd64.tar.gz"
 sudo mv /usr/local/bin/libpixlet.so /usr/lib/libpixlet.so
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f0e2d84f..993d1497 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -30,7 +30,7 @@ jobs:
           sudo mv /usr/local/bin/libpixlet.so /usr/lib/libpixlet.so
           rm "pixlet_${PIXLET_VERSION}_linux_amd64.tar.gz"
         env:
-          PIXLET_VERSION: v0.41.0
+          PIXLET_VERSION: v0.42.0
       - name: Test with pytest
         run: |
           pip install pytest
diff --git a/tronbyt_server/__init__.py b/tronbyt_server/__init__.py
index 9d5c6b35..69e92129 100644
--- a/tronbyt_server/__init__.py
+++ b/tronbyt_server/__init__.py
@@ -5,7 +5,7 @@
 import time
 from pathlib import Path
 from threading import Lock
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, List, Optional, Tuple
 
 from babel.dates import format_timedelta
 from dotenv import load_dotenv
@@ -59,24 +59,28 @@ def load_pixlet_library() -> None:
         ctypes.c_int,
     ]
 
-    class DataReturn(ctypes.Structure):
+    # Use c_void_p for the return type to avoid ctype's automatic copying into bytes() objects.
+    # We need the exact pointer value so that we can free it later using pixlet_free_bytes.
+    class RenderAppReturn(ctypes.Structure):
         _fields_ = [
-            ("data", ctypes.POINTER(ctypes.c_ubyte)),
+            ("data", ctypes.c_void_p),
             ("length", ctypes.c_int),
+            ("messages", ctypes.c_void_p),
+            ("error", ctypes.c_void_p),
         ]
 
     class StringReturn(ctypes.Structure):
         _fields_ = [
-            ("data", ctypes.c_char_p),
-            ("length", ctypes.c_int),
+            ("data", ctypes.c_void_p),
+            ("status", ctypes.c_int),
         ]
 
-    pixlet_render_app.restype = DataReturn
+    pixlet_render_app.restype = RenderAppReturn
 
     global pixlet_get_schema
     pixlet_get_schema = pixlet_library.get_schema
     pixlet_get_schema.argtypes = [ctypes.c_char_p]
-    pixlet_get_schema.restype = DataReturn
+    pixlet_get_schema.restype = StringReturn
 
     global pixlet_call_handler
     pixlet_call_handler = pixlet_library.call_handler
@@ -113,6 +117,16 @@ def initialize_pixlet_library() -> None:
         _pixlet_initialized = True
 
 
+def c_char_p_to_string(c_pointer: ctypes.c_char_p) -> Optional[str]:
+    if not c_pointer:
+        return None
+    data = ctypes.string_at(c_pointer)  # Extract the NUL-terminated C-String
+    result = data.decode("utf-8")  # Decode the C-String to Python string
+    if pixlet_free_bytes:
+        pixlet_free_bytes(c_pointer)  # Free the original C pointer
+    return result
+
+
 def render_app(
     path: Path,
     config: Dict[str, Any],
@@ -122,11 +136,10 @@ def render_app(
     maxDuration: int,
     timeout: int,
     image_format: int,
-    silence_output: bool,
-) -> Optional[bytes]:
+) -> Tuple[Optional[bytes], List[str]]:
     initialize_pixlet_library()
     if not pixlet_render_app:
-        return None
+        return None, []
     ret = pixlet_render_app(
         str(path).encode("utf-8"),
         json.dumps(config).encode("utf-8"),
@@ -136,8 +149,12 @@ def render_app(
         maxDuration,
         timeout,
         image_format,
-        1 if silence_output else 0,
+        1,
     )
+    error = c_char_p_to_string(ret.error)
+    messagesJSON = c_char_p_to_string(ret.messages)
+    if error:
+        current_app.logger.error(f"Error while rendering {path}: {error}")
     if ret.length >= 0:
         data = ctypes.cast(
             ret.data, ctypes.POINTER(ctypes.c_byte * ret.length)
@@ -145,8 +162,14 @@ def render_app(
         buf = bytes(data)
         if pixlet_free_bytes and ret.data:
             pixlet_free_bytes(ret.data)
-        return buf
-    return None
+        if messagesJSON:
+            try:
+                messages = json.loads(messagesJSON)
+            except Exception as e:
+                current_app.logger.error(f"Error: {e}")
+                messages = []
+        return buf, messages
+    return None, []
 
 
 def get_schema(path: Path) -> Optional[str]:
@@ -154,19 +177,10 @@ def get_schema(path: Path) -> Optional[str]:
     if not pixlet_get_schema:
         return None
     ret = pixlet_get_schema(str(path).encode("utf-8"))
-    if ret.length >= 0:
-        data = ctypes.cast(
-            ret.data, ctypes.POINTER(ctypes.c_byte * ret.length)
-        ).contents
-        try:
-            buf = bytes(data).decode("utf-8")
-        except UnicodeDecodeError as e:
-            current_app.logger.error(f"UnicodeDecodeError: {e}")
-            buf = None
-        if pixlet_free_bytes and ret.data:
-            pixlet_free_bytes(ret.data)
-        return buf
-    return None
+    schema = c_char_p_to_string(ret.data)
+    if ret.status != 0:
+        return None
+    return schema
 
 
 def call_handler(path: Path, handler: str, parameter: str) -> Optional[str]:
@@ -178,15 +192,10 @@ def call_handler(path: Path, handler: str, parameter: str) -> Optional[str]:
         ctypes.c_char_p(handler.encode("utf-8")),
         ctypes.c_char_p(parameter.encode("utf-8")),
     )
-    if ret.length >= 0:
-        data = ctypes.string_at(ret.data)
-        try:
-            buf = data.decode("utf-8")
-        except Exception as e:
-            current_app.logger.error(f"Error: {e}")
-            buf = None
-        return buf
-    return None
+    res = c_char_p_to_string(ret.data)
+    if ret.status != 0:
+        return None
+    return res
 
 
 def get_locale() -> Optional[str]:
diff --git a/tronbyt_server/manager.py b/tronbyt_server/manager.py
index 850c7f52..b415429a 100644
--- a/tronbyt_server/manager.py
+++ b/tronbyt_server/manager.py
@@ -529,6 +529,11 @@ def updateapp(device_id: str, iname: str) -> ResponseReturnValue:
     )
 
 
+def add_default_config(config: Dict[str, Any], device: Device) -> Dict[str, Any]:
+    config["$tz"] = db.get_device_timezone_str(device)
+    return config
+
+
 @bp.get("/<string:device_id>/<string:iname>/preview")
 @login_required
 def preview(device_id: str, iname: str) -> ResponseReturnValue:
@@ -617,7 +622,7 @@ def render_app(
     config_data = config.copy()  # Create a copy to avoid modifying the original config
     add_default_config(config_data, device)
 
-    data = pixlet_render_app(
+    data, messages = pixlet_render_app(
         path=app_path,
         config=config_data,
         width=64,
@@ -626,11 +631,13 @@ def render_app(
         maxDuration=15000,
         timeout=30000,
         image_format=0,  # 0 == WebP
-        silence_output=current_app.config.get("PRODUCTION") == "1",
     )
     if data is None:
         current_app.logger.error("Error running pixlet render")
         return None
+    if messages and current_app.config.get("PRODUCTION") != "1":
+        current_app.logger.debug(f"{app_path}: {messages}")
+
     # leave the previous file in place if the new one is empty
     # this way, we still display the last successful render on the index page,
     # even if the app returns no screens
@@ -738,11 +745,6 @@ def generate_firmware(device_id: str) -> ResponseReturnValue:
     )
 
 
-def add_default_config(config: Dict[str, Any], device: Device) -> Dict[str, Any]:
-    config["$tz"] = db.get_device_timezone_str(device)
-    return config
-
-
 @bp.route(
     "/<string:device_id>/<string:iname>/<int:delete_on_cancel>/configapp",
     methods=["GET", "POST"],


Reviews:
user: login: gemini-code-assist[bot]
body: Hello @IngmarStein, I'm Gemini Code Assist[^1]! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

## Summary of Changes
This pull request modifies the `tronbyt_server` to use the app output returned by the `libpixlet` library. It updates the data structures used for communication between the server and the library, and it introduces a utility function to convert C-style strings to Python strings. The changes also include modifications to how the schema is retrieved and how the app is rendered, including error handling and message processing. Additionally, a function was added to add default configurations to the app, and a seemingly unused function was moved to be closer to where it is used.

### Highlights
* **Data Structure Updates**: The `DataReturn` and `StringReturn` structures in `tronbyt_server/__init__.py` have been updated to use `c_void_p` for data pointers, and the `RenderAppReturn` structure now includes fields for messages and errors.
* **C String Conversion**: A new utility function, `c_char_p_to_string`, has been added to convert C-style strings (returned as `c_char_p`) to Python strings, including memory management for the C string.
* **Rendering Logic**: The `render_app` function now returns a tuple containing the rendered image data and a list of messages. It also includes error handling for rendering errors and message processing.
* **Schema Retrieval**: The `get_schema` function now uses the `c_char_p_to_string` function to retrieve the schema and checks the status code before returning the schema.
* **Configuration Management**: The `add_default_config` function has been added to `tronbyt_server/manager.py` to add default configurations to the app.

### Changelog
<details>
<summary>Click here to see the changelog</summary>

* **tronbyt_server/__init__.py**
  * Updated `DataReturn` and `StringReturn` structures to use `c_void_p`.
  * Added `RenderAppReturn` structure with fields for data, messages, and errors.
  * Added `c_char_p_to_string` function to convert C strings to Python strings.
  * Modified `render_app` to return a tuple of image data and messages, and to handle errors.
  * Modified `get_schema` to use `c_char_p_to_string` and check the status code.
  * Modified `call_handler` to use `c_char_p_to_string` and check the status code.
  * Removed `silence_output` parameter from `pixlet_render_app` call.
* **tronbyt_server/manager.py**
  * Added `add_default_config` function to add default configurations to the app.
  * Modified `render_app` to receive messages from `pixlet_render_app`.
  * Moved `add_default_config` function to be closer to where it is used.

</details>


<details>

<summary><b>Using Gemini Code Assist</b></summary>
<br>

The full guide for Gemini Code Assist can be found on our [documentation page](https://developers.google.com/gemini-code-assist/docs/review-github-code), here are some quick tips.

<b>Invoking Gemini</b>

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either `/gemini <command>` or `@gemini-code-assist <command>`. Below is a summary of the supported commands.

Feature | Command | Description
--- | --- | ---
Code Review | `/gemini review` | Performs a code review for the current pull request in its current state.
Pull Request Summary | `/gemini summary` | Provides a summary of the current pull request in its current state.
Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments.
Help | `/gemini help` | Displays a list of available commands.

<b>Customization</b>

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a `.gemini/` folder in the base of the repository. Detailed instructions can be found [here](https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github).

<b>Limitations & Feedback</b>

Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with :thumbsup: and :thumbsdown: on @gemini-code-assist comments to provide feedback.

</details>

<b>You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the [Gemini Code Assist IDE Extension](https://cloud.google.com/products/gemini/code-assist).</b>


[^1]: Review the [Privacy Notices](https://policies.google.com/privacy), [Generative AI Prohibited Use Policy](https://policies.google.com/terms/generative-ai/use-policy), [Terms of Service](https://policies.google.com/terms), and learn how to configure Gemini Code Assist in GitHub [here](https://developers.google.com/gemini-code-assist/docs/review-github-code). Gemini can make mistakes, so double check it and [use code with caution](https://support.google.com/legal/answer/13505487).

---

> From C strings,
> To Python's embrace they leap,
> A bridge of code built.


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:06:40Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:06:53Z
user: login: gemini-code-assist[bot]
body: ## Code Review
The pull request introduces changes to how the server interacts with the `libpixlet` library, specifically focusing on retrieving app output, messages, and errors. The changes involve modifying data structures, function signatures, and error handling logic. Overall, the changes seem well-structured and address the issue of capturing more detailed output from `libpixlet`.

### Summary of Findings
* **Error Handling**: The introduction of error handling and message retrieval from `libpixlet` is a significant improvement. The code now captures and logs errors, providing better insights into app rendering issues.
* **Memory Management**: The use of `c_void_p` and the explicit freeing of memory with `pixlet_free_bytes` is crucial for preventing memory leaks. This is especially important when dealing with C libraries in Python.
* **Return Value Handling**: The changes to return values of `render_app`, `get_schema`, and `call_handler` to include status or messages provide more context and control over the application's behavior.

### Merge Readiness
The pull request is well-structured and addresses the issue of capturing more detailed output from `libpixlet`. The introduction of error handling and message retrieval from `libpixlet` is a significant improvement. The code now captures and logs errors, providing better insights into app rendering issues. The use of `c_void_p` and the explicit freeing of memory with `pixlet_free_bytes` is crucial for preventing memory leaks. This is especially important when dealing with C libraries in Python. The changes to return values of `render_app`, `get_schema`, and `call_handler` to include status or messages provide more context and control over the application's behavior. I am unable to approve this pull request, and recommend that others review and approve this code before merging.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T16:06:59Z

Review comments:
diff_hunk: @@ -136,37 +149,38 @@ def render_app(
         maxDuration,
         timeout,
         image_format,
-        1 if silence_output else 0,
+        1,
     )
+    error = c_char_p_to_string(ret.error)
+    messagesJSON = c_char_p_to_string(ret.messages)
+    if error:
+        current_app.logger.error(f"Error while rendering {path}: {error}")
     if ret.length >= 0:
         data = ctypes.cast(
             ret.data, ctypes.POINTER(ctypes.c_byte * ret.length)
         ).contents
         buf = bytes(data)
         if pixlet_free_bytes and ret.data:
             pixlet_free_bytes(ret.data)
-        return buf
-    return None
+        if messagesJSON:
+            try:
+                messages = json.loads(messagesJSON)
path: tronbyt_server/__init__.py
user: login: Copilot
body: The json module is being used without an import statement. Consider adding 'import json' at the top of the file to avoid runtime errors.
created_at: 2025-04-14T16:06:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -122,8 +136,7 @@ def render_app(
     maxDuration: int,
     timeout: int,
     image_format: int,
-    silence_output: bool,
-) -> Optional[bytes]:
+) -> Tuple[Optional[bytes], List[str]]:
path: tronbyt_server/__init__.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Returning a tuple `Tuple[Optional[bytes], List[str]]` is a good way to provide both the image data and any messages. Consider adding a docstring to explain the purpose and structure of the return value.

```python
) -> Tuple[Optional[bytes], List[str]]:
    """Renders the app and returns the image data and any messages.

    Returns:
        Tuple[Optional[bytes], List[str]]: A tuple containing the image data (if successful) and a list of messages.
    """
```
created_at: 2025-04-14T16:06:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,37 +149,38 @@ def render_app(
         maxDuration,
         timeout,
         image_format,
-        1 if silence_output else 0,
+        1,
path: tronbyt_server/__init__.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The `render_app` function always passes `1` for the `silence_output` parameter. This effectively disables the silencing of output. Is this intentional? If the intention is to always show the output, the parameter should be removed from the function definition.

```python
        timeout,
        image_format,
    )
```
created_at: 2025-04-14T16:06:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,37 +149,38 @@ def render_app(
         maxDuration,
         timeout,
         image_format,
-        1 if silence_output else 0,
+        1,
     )
+    error = c_char_p_to_string(ret.error)
+    messagesJSON = c_char_p_to_string(ret.messages)
+    if error:
+        current_app.logger.error(f"Error while rendering {path}: {error}")
     if ret.length >= 0:
         data = ctypes.cast(
             ret.data, ctypes.POINTER(ctypes.c_byte * ret.length)
         ).contents
         buf = bytes(data)
         if pixlet_free_bytes and ret.data:
             pixlet_free_bytes(ret.data)
-        return buf
-    return None
+        if messagesJSON:
+            try:
+                messages = json.loads(messagesJSON)
+            except Exception as e:
+                current_app.logger.error(f"Error: {e}")
+                messages = []
path: tronbyt_server/__init__.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

Consider adding a more specific exception handler for `json.JSONDecodeError` to differentiate it from other potential exceptions. This would allow for more targeted error logging and handling.

```python
            try:
                messages = json.loads(messagesJSON)
            except json.JSONDecodeError as e:
                current_app.logger.error(f"JSONDecodeError: {e}")
                messages = []
            except Exception as e:
                current_app.logger.error(f"Error: {e}")
                messages = []
```
created_at: 2025-04-14T16:06:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -626,11 +631,13 @@ def render_app(
         maxDuration=15000,
         timeout=30000,
         image_format=0,  # 0 == WebP
-        silence_output=current_app.config.get("PRODUCTION") == "1",
     )
     if data is None:
         current_app.logger.error("Error running pixlet render")
         return None
+    if messages and current_app.config.get("PRODUCTION") != "1":
+        current_app.logger.debug(f"{app_path}: {messages}")
path: tronbyt_server/manager.py
user: login: gemini-code-assist[bot]
body: ![medium](https://img.shields.io/badge/priority-medium-yellow)

The logging of messages in non-production environments is helpful for debugging. Consider adding a level check to ensure that debug logging is enabled before logging the messages.

```suggestion
    if messages and current_app.config.get("PRODUCTION") != "1" and current_app.debug:
        current_app.logger.debug(f"{app_path}: {messages}")
```
created_at: 2025-04-14T16:06:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: tavdog
created_at: 2025-04-15T08:47:42Z
body: Dang! Nice work.  
author_association: MEMBER
user: login: IngmarStein
created_at: 2025-04-15T09:49:33Z
body: Thanks! Would you like to take over the UI part? Maybe we'll want to store the last render output in the database and display it on the configapp page.
author_association: MEMBER
user: login: tavdog
created_at: 2025-04-15T20:07:46Z
body: > Thanks! Would you like to take over the UI part? Maybe we'll want to store the last render output in the database and display it on the configapp page.

Sure i'll work on stashing it in the DB file display.
author_association: MEMBER