chore(gdb): add dump dashboard command with HTML renderer and data collector (#9870)

This commit is contained in:
Benign X
2026-03-19 13:48:19 +08:00
committed by GitHub
parent 623624b3e0
commit cd07338461
12 changed files with 3008 additions and 93 deletions

View File

@@ -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 <expr>``: 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 <expr>``: 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 <path>`` 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<br/>dump obj, info style, ..."]
DASH["HTML Dashboard<br/>dump dashboard"]
end
subgraph "Formatter / Renderer"
FMT["formatter.py<br/>print_info · print_spec_table"]
HR["html_renderer.py<br/>template + CSS + JS"]
end
subgraph "Data Collection"
DC["data_collector.py<br/>collect_all() → JSON dict"]
SNAP["Snapshot<br/>_data + _display_spec"]
end
subgraph "Value Wrappers (lvgl/)"
W["LVObject · LVDisplay · LVAnim<br/>LVCache · LVTimer · LVDrawBuf<br/>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:

View File

@@ -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 <layer_expr>
# 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 <layer_expr> # 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<br/>(mempython object)"]
gdb_cmds["gdb_cmds<br/>(gdb commands)"]
lvglgdb["lvglgdb"]
lvgl["lvgl<br/>(mempython objects)"]
cmds["cmds<br/>(GDB commands)"]
formatter["formatter<br/>(display logic)"]
dashboard["cmds/dashboard<br/>(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
```

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
from .lv_dashboard import DumpDashboard
__all__ = ["DumpDashboard"]

View File

@@ -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.<dict_key>().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.<accessor>()]
For entries with accessor == None: [s.as_dict() for s in lvgl.<dict_key>().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)

View File

@@ -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 <script> block
return raw.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e")
def _read_static(filename: str) -> str:
"""Read a static asset file from the static/ directory."""
return (_STATIC_DIR / filename).read_text(encoding="utf-8")
def _build_html(json_content: str) -> str:
"""Build the complete HTML page with embedded or empty JSON."""
template = _read_static("template.html")
css = _read_static("style.css")
js = _read_static("dashboard.js")
# Deterministic single-replacement order: CSS → JS → JSON_DATA.
# Each placeholder is replaced exactly once (count=1) so that content
# injected in an earlier step cannot be mis-interpreted as a later placeholder.
return (
template
.replace("{{CSS}}", css, 1)
.replace("{{JS}}", js, 1)
.replace("{{JSON_DATA}}", json_content, 1)
)

View File

@@ -0,0 +1,58 @@
import argparse
import json
import time
import gdb
from lvglgdb.lvgl import curr_inst
from .data_collector import collect_all
from .html_renderer import render, render_viewer
class DumpDashboard(gdb.Command):
"""Generate an HTML dashboard of all LVGL runtime state."""
def __init__(self):
super().__init__(
"dump dashboard", gdb.COMMAND_USER, gdb.COMPLETE_EXPRESSION
)
def invoke(self, args, from_tty):
parser = argparse.ArgumentParser(prog="dump dashboard")
parser.add_argument("-o", "--output", help="output file path")
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--json", action="store_true", help="output JSON instead of HTML",
)
group.add_argument(
"--viewer", action="store_true",
help="output empty viewer HTML (no data collection)",
)
try:
opts = parser.parse_args(gdb.string_to_argv(args))
except SystemExit:
return
if opts.viewer:
out = opts.output or "lvgl_viewer.html"
render_viewer(out)
gdb.write(f"Viewer written to {out}\n")
return
if not curr_inst().ensure_init():
return
t0 = time.time()
data = collect_all()
elapsed = time.time() - t0
if opts.json:
out = opts.output or "lvgl_dashboard.json"
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
else:
out = opts.output or "lvgl_dashboard.html"
render(data, out)
gdb.write(f"Dashboard written to {out} ({elapsed:.2f}s)\n")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LVGL Dashboard</title>
<style>
{{CSS}}
</style>
</head>
<body>
<header class="topbar">
<div class="topbar-brand">
<span class="logo">LV</span> LVGL Dashboard
</div>
<div class="topbar-sep"></div>
<div class="topbar-meta" id="header-meta"></div>
<nav class="topbar-nav" id="topbar-nav"></nav>
<button class="theme-toggle" id="theme-toggle" title="Toggle light/dark theme"
aria-label="Toggle theme">🌙</button>
<input type="text" class="topbar-search" id="search"
placeholder="Filter..." aria-label="Search">
</header>
<main class="main">
<div class="bento" id="bento-grid">
<div id="drop-zone" style="display:none">
<div class="drop-icon">📂</div>
<p>Drag &amp; drop a JSON file here, or click to select</p>
<div class="drop-hint">Exported via <code>dump dashboard --json</code></div>
<input type="file" id="file-input" accept=".json" aria-label="Load JSON file">
</div>
</div>
</main>
<script type="application/json" id="lvgl-data">{{JSON_DATA}}</script>
<script>
{{JS}}
</script>
</body>
</html>

View File

@@ -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]:

View File

@@ -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"