diff --git a/docs/src/debugging/gdb_plugin.rst b/docs/src/debugging/gdb_plugin.rst index 060e734d65..2c092f123f 100644 --- a/docs/src/debugging/gdb_plugin.rst +++ b/docs/src/debugging/gdb_plugin.rst @@ -49,6 +49,7 @@ The plugin provides the following commands. - ``dump image_decoder``: List all registered image decoders. - ``dump fs_drv``: List all registered filesystem drivers. - ``dump draw_task ``: List draw tasks from a layer. +- ``dump dashboard``: Generate an HTML dashboard of all LVGL runtime state. - ``info style``: Inspect style properties of an ``lv_style_t`` or an ``lv_obj_t``. - ``info draw_unit``: Print raw struct details for each drawing unit. - ``info obj_class ``: Show object class hierarchy. @@ -192,6 +193,42 @@ Dump Draw Tasks and display each task's type, state, area, opacity, and preferred draw unit id. +Dump Dashboard +************** + +``dump dashboard``: Collect all LVGL runtime state (displays, object trees, +animations, timers, caches, input devices, groups, draw units/tasks, +subjects/observers, image decoders, filesystem drivers) and generate a +self-contained HTML file for offline browsing. + +The dashboard supports three output modes: + +- ``dump dashboard``: Generate ``lvgl_dashboard.html`` with all data embedded. +- ``dump dashboard --json``: Export raw JSON data to ``lvgl_dashboard.json``. +- ``dump dashboard --viewer``: Generate an empty HTML viewer (``lvgl_viewer.html``) + that can load JSON files via drag-and-drop. + +Use ``-o `` to specify a custom output path. + +Example: + +.. code:: bash + + (gdb) dump dashboard + Dashboard written to lvgl_dashboard.html (1.23s) + + (gdb) dump dashboard --json -o /tmp/state.json + Dashboard written to /tmp/state.json (0.98s) + + (gdb) dump dashboard --viewer + Viewer written to lvgl_viewer.html + +The generated HTML is fully self-contained (no external dependencies) and +includes a sidebar for navigation, a search box for filtering, collapsible +object trees with style details, framebuffer image previews, and cross-reference +links between related objects. + + Inspect Object Class ******************** @@ -289,14 +326,58 @@ bulk export (e.g. ``LVAnim.snapshots(anims)``). Additionally, Architecture ************ -The GDB plugin decouples data extraction from display through a snapshot -abstraction. Each wrapper class (``LVAnim``, ``LVTimer``, ``LVObject``, etc.) -declares a ``_DISPLAY_SPEC`` describing its fields and exports a ``snapshot()`` -method that returns a self-describing ``Snapshot`` object carrying both the -data dict and the display spec. The ``cmds/`` layer simply passes snapshots to -generic formatters (``print_info``, ``print_spec_table``) which read the -embedded spec to render output — no command needs to know the internal -structure of any wrapper. +The GDB plugin is organized into four layers. The overview below shows how +terminal commands and the HTML dashboard both flow through the same snapshot +abstraction down to raw GDB memory access: + +.. mermaid:: + :zoom: + + graph TD + subgraph "Rendering Layer" + CLI["GDB Terminal
dump obj, info style, ..."] + DASH["HTML Dashboard
dump dashboard"] + end + + subgraph "Formatter / Renderer" + FMT["formatter.py
print_info · print_spec_table"] + HR["html_renderer.py
template + CSS + JS"] + end + + subgraph "Data Collection" + DC["data_collector.py
collect_all() → JSON dict"] + SNAP["Snapshot
_data + _display_spec"] + end + + subgraph "Value Wrappers (lvgl/)" + W["LVObject · LVDisplay · LVAnim
LVCache · LVTimer · LVDrawBuf
LVIndev · LVGroup · ..."] + GDB["gdb.Value (C struct memory)"] + end + + CLI --> FMT + FMT --> SNAP + DASH --> HR + HR --> DC + DC --> SNAP + SNAP --> W + W --> GDB + + style CLI fill:#4CAF50,color:#fff + style DASH fill:#4CAF50,color:#fff + style FMT fill:#FF9800,color:#fff + style HR fill:#FF9800,color:#fff + style DC fill:#2196F3,color:#fff + style SNAP fill:#2196F3,color:#fff + style W fill:#9C27B0,color:#fff + style GDB fill:#616161,color:#fff + +Each wrapper class declares a ``_DISPLAY_SPEC`` describing its fields and +exports a ``snapshot()`` method that returns a self-describing ``Snapshot`` +object carrying both the data dict and the display spec. The ``cmds/`` layer +simply passes snapshots to generic formatters (``print_info``, +``print_spec_table``) which read the embedded spec to render output — no +command needs to know the internal structure of any wrapper. The detailed +snapshot flow is shown below: .. mermaid:: :zoom: diff --git a/scripts/gdb/README.md b/scripts/gdb/README.md index 77c3ed7f01..4fcb50922d 100644 --- a/scripts/gdb/README.md +++ b/scripts/gdb/README.md @@ -1,61 +1,72 @@ # lvglgdb -lvglgdb is a GDB script for LVGL. +GDB Python extension for inspecting and debugging LVGL internals. +Works with live debugging sessions, core dumps, and other +GDB-compatible targets. -# Installation +## Installation ```bash pip install lvglgdb ``` -# Simple Usage +## Usage + +In your GDB session: -In your GDB session, run: ```bash py import lvglgdb - -dump obj -dump display -f png -dump cache image -dump cache image_header -check cache image -dump anim -dump timer -dump indev -dump group -dump image_decoder -dump fs_drv -dump draw_task - -# Inspect a single lv_style_t variable -info style my_style - -# Inspect all styles of an lv_obj_t -info style --obj my_obj - -# Show draw unit information -info draw_unit - -# Show object class hierarchy -info obj_class obj->class_p - -# Show subject and its observers -info subject &my_subject ``` -# Structure +### Dump Commands + +```bash +dump obj # Dump widget tree +dump display -f png # Dump display framebuffer as PNG +dump cache image # Dump image cache entries +dump cache image_header # Dump image header cache entries +check cache image # Validate image cache integrity +dump anim # Dump active animations +dump timer # Dump registered timers +dump indev # Dump input devices +dump group # Dump focus groups +dump image_decoder # Dump registered image decoders +dump fs_drv # Dump filesystem drivers +dump draw_task # Dump draw tasks for a layer +dump dashboard # Generate interactive HTML dashboard +dump dashboard -o out.html # Save dashboard to file +``` + +### Info Commands + +```bash +info style my_style # Inspect a single lv_style_t +info style --obj my_obj # Inspect all styles of an lv_obj_t +info draw_unit # Show draw unit information +info obj_class obj->class_p # Show object class hierarchy +info subject &my_subject # Show subject and its observers +``` + +### Dashboard + +`dump dashboard` generates a self-contained HTML file with an interactive 3D +layer view, widget tree, style inspector, cache stats, animation list, and +draw buffer previews (RGB565 / RGB888 / ARGB8888 / XRGB8888). + +## Structure ```mermaid graph TD - lvgl["lvgl
(mem→python object)"] - gdb_cmds["gdb_cmds
(gdb commands)"] - lvglgdb["lvglgdb"] + lvgl["lvgl
(mem → python objects)"] + cmds["cmds
(GDB commands)"] + formatter["formatter
(display logic)"] + dashboard["cmds/dashboard
(HTML renderer)"] - lvglgdb --> lvgl - lvglgdb --> gdb_cmds - gdb_cmds --> lvgl + cmds --> formatter + cmds --> lvgl + dashboard --> lvgl + formatter --> lvgl classDef pkg fill:white,stroke:gray - classDef core fill:white,stroke:gray - class lvglgdb,lvgl,gdb_cmds pkg + class lvgl,cmds,formatter,dashboard pkg ``` diff --git a/scripts/gdb/lvglgdb/cmds/__init__.py b/scripts/gdb/lvglgdb/cmds/__init__.py index 3ecf4f674a..db60636fba 100644 --- a/scripts/gdb/lvglgdb/cmds/__init__.py +++ b/scripts/gdb/lvglgdb/cmds/__init__.py @@ -13,6 +13,7 @@ from .misc import ( DumpImageDecoder, DumpFsDrv, ) +from .dashboard import DumpDashboard from .debugger import Debugger from .drivers import Lvglobal @@ -50,3 +51,6 @@ InfoSubject() # Drivers Lvglobal() + +# Dashboard +DumpDashboard() diff --git a/scripts/gdb/lvglgdb/cmds/dashboard/__init__.py b/scripts/gdb/lvglgdb/cmds/dashboard/__init__.py new file mode 100644 index 0000000000..e8961e4b37 --- /dev/null +++ b/scripts/gdb/lvglgdb/cmds/dashboard/__init__.py @@ -0,0 +1,3 @@ +from .lv_dashboard import DumpDashboard + +__all__ = ["DumpDashboard"] diff --git a/scripts/gdb/lvglgdb/cmds/dashboard/data_collector.py b/scripts/gdb/lvglgdb/cmds/dashboard/data_collector.py new file mode 100644 index 0000000000..f95569227f --- /dev/null +++ b/scripts/gdb/lvglgdb/cmds/dashboard/data_collector.py @@ -0,0 +1,232 @@ +import base64 +import functools +from datetime import datetime + +import gdb + + +def safe_collect(subsystem: str): + """Decorator that wraps a collector function with try/except/warning.""" + def decorator(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception as e: + gdb.write(f"Warning: failed to collect {subsystem}: {e}\n") + return [] + return wrapper + return decorator + + +# Registry of simple subsystems: (dict_key, lvgl_accessor_method, label) +# accessor=None means the subsystem uses lvgl.().snapshots() pattern +SIMPLE_REGISTRY: list[tuple[str, str | None, str]] = [ + ("animations", "anims", "animations"), + ("timers", "timers", "timers"), + ("indevs", "indevs", "indevs"), + ("groups", "groups", "groups"), + ("draw_units", "draw_units", "draw units"), + ("image_decoders", "image_decoders", "image decoders"), + ("fs_drivers", "fs_drivers", "fs drivers"), + ("image_header_cache", None, "image header cache"), +] + + +def _collect_simple(lvgl, dict_key: str, accessor: str | None, label: str) -> list: + """Collect a simple subsystem using the registry entry. + + For entries with accessor != None: [x.snapshot().as_dict() for x in lvgl.()] + For entries with accessor == None: [s.as_dict() for s in lvgl.().snapshots()] + """ + try: + if accessor is not None: + return [x.snapshot().as_dict() for x in getattr(lvgl, accessor)()] + else: + return [s.as_dict() for s in getattr(lvgl, dict_key)().snapshots()] + except Exception as e: + gdb.write(f"Warning: failed to collect {label}: {e}\n") + return [] + + +def collect_all() -> dict: + """Collect all LVGL runtime data into a JSON-compatible dict.""" + from lvglgdb.lvgl import curr_inst + + lvgl = curr_inst() + + data = { + "meta": { + "timestamp": datetime.now().astimezone().isoformat(), + "lvgl_version": _get_lvgl_version(), + }, + # Specialized collectors (complex logic, not registry-driven) + "displays": _collect_displays(lvgl), + "object_trees": _collect_object_trees(lvgl), + "image_cache": _collect_image_cache(lvgl), + "draw_tasks": _collect_draw_tasks(lvgl), + "subjects": _collect_subjects(lvgl), + } + # Registry-driven simple collectors + for dict_key, accessor, label in SIMPLE_REGISTRY: + data[dict_key] = _collect_simple(lvgl, dict_key, accessor, label) + return data + + +def _get_lvgl_version() -> str | None: + """Try to read LVGL version from macros.""" + try: + major = int(gdb.parse_and_eval("LVGL_VERSION_MAJOR")) + minor = int(gdb.parse_and_eval("LVGL_VERSION_MINOR")) + patch = int(gdb.parse_and_eval("LVGL_VERSION_PATCH")) + return f"{major}.{minor}.{patch}" + except gdb.error: + return None + + +def _buf_to_dict(draw_buf) -> dict | None: + """Convert an LVDrawBuf to a dict with base64 PNG image.""" + if draw_buf is None: + return None + try: + cf_info = draw_buf.color_format_info() + header = draw_buf.super_value("header") + stride = int(header["stride"]) + height = int(header["h"]) + bpp = cf_info["bpp"] + width = (stride * 8) // bpp if bpp else 0 + + png_bytes = draw_buf.to_png_bytes() + image_b64 = base64.b64encode(png_bytes).decode("ascii") if png_bytes else None + + return { + "addr": hex(int(draw_buf)), + "width": width, + "height": height, + "color_format": cf_info["name"], + "data_size": int(draw_buf.super_value("data_size")), + "image_base64": image_b64, + } + except Exception: + return None + + +@safe_collect("displays") +def _collect_displays(lvgl) -> list: + """Collect display info with framebuffer data.""" + result = [] + for disp in lvgl.displays(): + d = disp.snapshot().as_dict() + d["buf_1"] = _buf_to_dict(disp.buf_1) + d["buf_2"] = _buf_to_dict(disp.buf_2) + result.append(d) + return result + + +@safe_collect("object trees") +def _collect_object_trees(lvgl) -> list: + """Collect object trees for all displays with layer name annotations.""" + result = [] + for disp in lvgl.displays(): + # Read special layer pointers for name annotation + layer_addrs = {} + for name in ("bottom_layer", "act_scr", "top_layer", "sys_layer"): + try: + ptr = disp.super_value(name) + if int(ptr): + layer_addrs[int(ptr)] = name + except Exception: + pass + + tree = { + "display_addr": hex(int(disp)), + "screens": [], + } + for screen in disp.screens: + snap = screen.snapshot( + include_children=True, include_styles=True + ).as_dict() + # Annotate with layer name if this screen is a known layer + screen_addr = int(screen) + snap["layer_name"] = layer_addrs.get(screen_addr) + tree["screens"].append(snap) + result.append(tree) + return result + + +@safe_collect("image cache") +def _collect_image_cache(lvgl) -> list: + """Collect image cache entries with optional decoded buffer previews.""" + cache = lvgl.image_cache() + entries = cache.snapshots() + result = [] + for snap in entries: + d = snap.as_dict() + # Try to get preview of decoded buffer + d["preview_base64"] = None + decoded_addr = d.get("decoded_addr") + if decoded_addr and decoded_addr != "0x0": + try: + from lvglgdb.lvgl.draw.lv_draw_buf import LVDrawBuf + buf = LVDrawBuf(gdb.Value(int(decoded_addr, 16))) + png_bytes = buf.to_png_bytes() + if png_bytes: + d["preview_base64"] = base64.b64encode( + png_bytes + ).decode("ascii") + except Exception: + pass + result.append(d) + return result + + +@safe_collect("draw tasks") +def _collect_draw_tasks(lvgl) -> list: + """Collect draw tasks from each display's layer chain.""" + from lvglgdb.lvgl.draw.lv_draw_task import LVDrawTask + result = [] + for disp in lvgl.displays(): + layer = disp.super_value("layer_head") + while layer and int(layer): + task_head = layer["draw_task_head"] + if int(task_head): + for t in LVDrawTask(task_head): + result.append(t.snapshot().as_dict()) + layer = layer["next"] + return result + + +@safe_collect("subjects") +def _collect_subjects(lvgl) -> list: + """Collect subjects from object event lists across all displays.""" + seen = set() + result = [] + from lvglgdb.lvgl.core.lv_observer import LVSubject + for disp in lvgl.displays(): + for screen in disp.screens: + _collect_subjects_from_obj(screen, seen, result) + return result + + +def _collect_subjects_from_obj(obj, seen, result): + """Recursively collect subjects from an object's event list.""" + from lvglgdb.lvgl.core.lv_observer import LVSubject + + event_list = obj.event_list + if event_list: + for dsc in event_list: + try: + user_data = dsc.user_data + if not int(user_data): + continue + # Check if this looks like a subject (has subs_ll field) + subject = LVSubject(user_data) + addr = int(subject) + if addr not in seen: + seen.add(addr) + result.append(subject.snapshot().as_dict()) + except Exception: + continue + + for child in obj.children: + _collect_subjects_from_obj(child, seen, result) diff --git a/scripts/gdb/lvglgdb/cmds/dashboard/html_renderer.py b/scripts/gdb/lvglgdb/cmds/dashboard/html_renderer.py new file mode 100644 index 0000000000..cf40437cb8 --- /dev/null +++ b/scripts/gdb/lvglgdb/cmds/dashboard/html_renderer.py @@ -0,0 +1,47 @@ +import json +from pathlib import Path + +_STATIC_DIR = Path(__file__).parent / "static" + + +def render(data: dict, output_path: str) -> None: + """Generate self-contained HTML with JSON data embedded.""" + json_str = _safe_json_encode(data) + html = _build_html(json_str) + with open(output_path, "w", encoding="utf-8") as f: + f.write(html) + + +def render_viewer(output_path: str) -> None: + """Generate empty shell HTML viewer (no embedded data).""" + html = _build_html("") + with open(output_path, "w", encoding="utf-8") as f: + f.write(html) + + +def _safe_json_encode(data: dict) -> str: + """Serialize dict to JSON with HTML-safe escaping.""" + raw = json.dumps(data, ensure_ascii=False, indent=None) + # Escape HTML-special chars to prevent injection in + + + diff --git a/scripts/gdb/lvglgdb/lvgl/draw/lv_draw_buf.py b/scripts/gdb/lvglgdb/lvgl/draw/lv_draw_buf.py index db3e0602b7..0001e7e345 100644 --- a/scripts/gdb/lvglgdb/lvgl/draw/lv_draw_buf.py +++ b/scripts/gdb/lvglgdb/lvgl/draw/lv_draw_buf.py @@ -75,9 +75,50 @@ class LVDrawBuf(Value): """Get the buffer data ptr""" return self.super_value("data") - def data_dump(self, filename: str, format: str = None) -> bool: + def _read_image(self, strict: bool = False) -> Optional[Image.Image]: + """Read buffer data and convert to PIL Image. + + Args: + strict: If True, raise on any size mismatch instead of ignoring. + + Returns: + PIL.Image or None on failure. """ - Dump the buffer data to an image file. + header = self.super_value("header") + stride = int(header["stride"]) + height = int(header["h"]) + cf_info = self.color_format_info() + data_ptr = self.super_value("data") + data_size = int(self.super_value("data_size")) + width = (stride * 8) // cf_info["bpp"] if cf_info["bpp"] else 0 + expected_data_size = stride * height + + if not data_ptr or width <= 0 or height <= 0: + if strict: + raise ValueError(f"Invalid buffer: ptr={data_ptr}, {width}x{height}") + return None + if data_size < expected_data_size: + if strict: + raise ValueError( + f"Data too small: expected at least {expected_data_size}," + f" got {data_size}" + ) + return None + if data_size > expected_data_size and strict: + gdb.write( + f"Warning: data_size {data_size} exceeds expected" + f" {expected_data_size}, extra bytes will be ignored\n" + ) + + pixel_data = ( + gdb.selected_inferior() + .read_memory(int(data_ptr), expected_data_size) + .tobytes() + ) + return self._convert_to_image(pixel_data, width, height, cf_info["value"]) + + def data_dump(self, filename: str, format: str = None) -> bool: + """Dump the buffer data to an image file. Args: filename: Output file path @@ -87,57 +128,19 @@ class LVDrawBuf(Value): bool: True if successful, False otherwise """ try: - # Validate input parameters if not filename: raise ValueError("Output filename cannot be empty") - # Get buffer metadata - header = self.super_value("header") - stride = int(header["stride"]) - height = int(header["h"]) - cf_info = self.color_format_info() - data_ptr = self.super_value("data") - data_size = int(self.super_value("data_size")) - width = (stride * 8) // cf_info["bpp"] if cf_info["bpp"] else 0 - expected_data_size = stride * height - - # Validate buffer data - if not data_ptr: - raise ValueError("Data pointer is NULL") - if width <= 0 or height <= 0: - raise ValueError(f"Invalid dimensions: {width}x{height}") - if data_size <= 0: - raise ValueError(f"Invalid data size: {data_size}") - if data_size < expected_data_size: - raise ValueError( - f"Data size mismatch: expected {expected_data_size}, got {data_size}" - ) - elif data_size > expected_data_size: - gdb.write( - f"\033[93mData size mismatch: expected {expected_data_size}, got {data_size}\033[0m\n" - ) - - # Read pixel data - pixel_data = ( - gdb.selected_inferior() - .read_memory(int(data_ptr), expected_data_size) - .tobytes() - ) - if not pixel_data: - raise ValueError("Failed to read pixel data") - - # Process based on color format - img = self._convert_to_image(pixel_data, width, height, cf_info["value"]) + img = self._read_image(strict=True) if img is None: return False - # Determine output format output_format = ( format.upper() if format else Path(filename).suffix[1:].upper() or "BMP" ) - - # Save image img.save(filename, format=output_format) + + cf_info = self.color_format_info() print( f"Successfully saved {cf_info['name']} buffer as {output_format} to {filename}" ) @@ -153,6 +156,20 @@ class LVDrawBuf(Value): print(f"Unexpected error: {str(e)}") return False + def to_png_bytes(self) -> Optional[bytes]: + """Convert buffer to PNG bytes in memory. Returns None on failure.""" + import io + + try: + img = self._read_image(strict=False) + if img is None: + return None + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + except (gdb.MemoryError, Exception): + return None + def _convert_to_image( self, pixel_data: bytes, width: int, height: int, color_format: int ) -> Optional[Image.Image]: diff --git a/scripts/gdb/pyproject.toml b/scripts/gdb/pyproject.toml index 02c89180b5..23ba2a0c64 100644 --- a/scripts/gdb/pyproject.toml +++ b/scripts/gdb/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lvglgdb" -version = "0.3.0" +version = "0.4.0" description = "LVGL GDB scripts" readme = "README.md" requires-python = ">=3.10" @@ -11,6 +11,9 @@ dependencies = [ "prettytable~=3.16.0", ] +[tool.setuptools.package-data] +lvglgdb = ["cmds/dashboard/static/*"] + [project.urls] Homepage = "https://lvgl.io" Documentation = "https://docs.lvgl.io/master/debugging/gdb_plugin.html"