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
+4
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()
@@ -0,0 +1,3 @@
from .lv_dashboard import DumpDashboard
__all__ = ["DumpDashboard"]
@@ -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)
@@ -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)
)
@@ -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
@@ -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>
+60 -43
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]: