Crash diagnostics app rewrite (#679)

- Crash diagnostics app doesn't use LVGL anymore. It now uses a display `Device*` directly.
- When there's no callstack, show a message instead
- To continue after a crash, you can use a touch/keyboard/encoder device (from touch only)
This commit is contained in:
Ken Van Hoeylandt 2026-10-09 00:24:59 +02:00 committed by GitHub
parent 24ef79070a
commit 33a830cecf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 135 additions and 230 deletions

View File

@ -7,13 +7,8 @@
## Higher Priority
- Wi-Fi and Bluetooth settings app: scanning should start and gather results, then it should stop refreshing the scan results automatically. When scanning is finished, a paginated view should be shown with the results (as many as fit on the screen, but not more). There should be an icon on the toolbar to refresh scanning (looking glass? something else?) and this icon is only visible when the radio is enabled. When radio is enabled by the user, automatically start a scan. If the radio was already enabled and the app starts, start scanning.
- Update to LVGL 9.6.x (ESP-IDF and POSIX)
- Terminal app: on devices with pointer device: LVGL rendering mode should hide the toolbar by default, but tapping the terminal should toggle the visibility of the toolbar.
- Terminal app: should always run in non-LVGL mode if no PSRAM is present.
- Crash app should work without LVGL, like Boot app. It should wait for any input (keyboard or pointer) to continue.
- NimBLE looses pairing key after reboot
- CrashDiagnostics shouldn't show a QR when there's no callstack
- Move USB host task stacks to SPIRAM when available: esp32_usbhost*.cpp
- Add bold fonts for e-ink readability improvement
- Improve Setup: Add keyboard/keypad/encoder navigation explanation.
@ -49,7 +44,6 @@
- Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html
- Make USB host driver disabled by default, so it doesn't consume memory
- TactilityTool: Make API compatibility table (and check for compatibility in the tool itself)
- Bug: Crash handling app cannot be exited with an EncoderDevice. (current work-around is to manually reset the device)
- Refactor HttpServer into C code and move implementation to http-module
- Use GPS time to set/update the current time
- Make a URL handler. Use it for handling local files. Match file types with apps.

View File

@ -1,5 +1,7 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
@ -30,6 +32,12 @@ class BootScreen {
int logicalWidth() const { return (rotation % 2 == 0) ? panelWidth : panelHeight; }
int logicalHeight() const { return (rotation % 2 == 0) ? panelHeight : panelWidth; }
/** @return true when the image has a pixel at (x, y), with its colour as RGB565 */
using ImagePixel = std::function<bool(int x, int y, uint16_t& color)>;
/** Draws an image with text lines below it, centered on a black background */
void draw(int imageWidth, int imageHeight, const ImagePixel& imagePixel, const std::vector<std::string>& lines);
public:
~BootScreen();
@ -49,6 +57,14 @@ public:
* @param[in] lines text lines, wrapped when they're too long for the display
*/
void show(const std::string& logoPath, const std::vector<std::string>& lines);
/**
* Shows a QR code with text lines below it, centered on a black background. The set modules are white.
* @param[in] moduleCount the width and height of the QR code in modules
* @param[in] isModuleSet whether the module at (x, y) is set
* @param[in] lines text lines, wrapped when they're too long for the display
*/
void showQrCode(int moduleCount, const std::function<bool(int x, int y)>& isModuleSet, const std::vector<std::string>& lines);
};
/**

View File

@ -2,9 +2,18 @@
#ifdef ESP_PLATFORM
namespace tt::app::boot {
class BootScreen;
}
namespace tt::app::crashdiagnostics {
void start();
/**
* Writes the crash log and shows the crash with a QR code for reporting it, after a panic reboot.
* Returns when the user continues with any input, and blocks forever when there's no input device to continue with.
* @param[in] screen the boot screen, which may have no display
*/
void showCrashScreen(boot::BootScreen& screen);
}

View File

@ -1,7 +0,0 @@
#pragma once
namespace tt::app::launcher {
uint32_t start();
}

View File

@ -81,7 +81,6 @@ namespace app {
#ifdef ESP_PLATFORM
namespace apwebserver { extern const ::AppManifest manifest; }
namespace camera { extern const ::AppManifest manifest; }
namespace crashdiagnostics { extern const ::AppManifest manifest; }
#if CONFIG_TT_TDECK_WORKAROUND == 1
namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now
#endif
@ -152,7 +151,6 @@ static void registerInternalApps() {
if (device_exists_of_type(&CAMERA_TYPE)) {
app_manager_add(&app::camera::manifest);
}
app_manager_add(&app::crashdiagnostics::manifest);
#if defined(CONFIG_TT_TDECK_WORKAROUND)
app_manager_add(&app::keyboardsettings::manifest);
#endif

View File

@ -187,13 +187,6 @@ void waitForInputAndReboot() {
}
void startNextApp() {
#ifdef ESP_PLATFORM
if (esp_reset_reason() == ESP_RST_PANIC) {
crashdiagnostics::start(); // fire-and-forget; no result expected back
return;
}
#endif
auto launcher_app_id = getLauncherAppId();
uint32_t launcher_instance_id = 0;
AppStartContext context;
@ -271,6 +264,12 @@ bool bootInit(TickType_t startTime) {
return false;
}
#ifdef ESP_PLATFORM
if (esp_reset_reason() == ESP_RST_PANIC) {
crashdiagnostics::showCrashScreen(screen);
}
#endif
LOG_I(TAG, "Loading fonts");
lvgl::loadFonts(lvgl::loadFontConfiguration());
lvgl::configureTheme(settings::appearance::loadOrGetDefault());

View File

@ -154,7 +154,41 @@ void BootScreen::show(const std::string& logoPath, const std::vector<std::string
}
}
// Layout in logical (rotated) coordinates: logo with text below it, centered as a whole
draw(static_cast<int>(logo.width), static_cast<int>(logo.height), [&logo](int x, int y, uint16_t& color) {
const unsigned char* pixel = logo.rgba + (static_cast<size_t>(y) * logo.width + x) * 4;
const uint32_t alpha = pixel[3];
if (alpha == 0) {
return false;
}
// Blended onto the black background
color = toRgb565(pixel[0] * alpha / 255, pixel[1] * alpha / 255, pixel[2] * alpha / 255);
return true;
}, lines);
}
void BootScreen::showQrCode(int moduleCount, const std::function<bool(int x, int y)>& isModuleSet, const std::vector<std::string>& lines) {
if (target == nullptr || moduleCount <= 0) {
return;
}
// About 60% of the display, which leaves room for the text
const int module_size = std::max(1, getSmallestDimension() * 6 / 10 / moduleCount);
const int size = module_size * moduleCount;
draw(size, size, [&isModuleSet, module_size](int x, int y, uint16_t& color) {
if (!isModuleSet(x / module_size, y / module_size)) {
return false;
}
color = 0xFFFF;
return true;
}, lines);
}
void BootScreen::draw(int imageWidth, int imageHeight, const ImagePixel& imagePixel, const std::vector<std::string>& lines) {
if (target == nullptr) {
return;
}
// Layout in logical (rotated) coordinates: the image with text below it, centered as a whole
if (!lines.empty() && textFont == nullptr) {
textFont = lvgl::loadMonoFont();
if (textFont == nullptr) {
@ -179,10 +213,10 @@ void BootScreen::show(const std::string& logoPath, const std::vector<std::string
}
}
const int text_height = static_cast<int>(wrapped_lines.size()) * line_height;
const int gap = (logo.height > 0 && !wrapped_lines.empty()) ? line_height : 0;
const int content_top = (logical_height - static_cast<int>(logo.height) - gap - text_height) / 2;
const int logo_left = (logical_width - static_cast<int>(logo.width)) / 2;
const int text_top = content_top + static_cast<int>(logo.height) + gap;
const int gap = (imageHeight > 0 && !wrapped_lines.empty()) ? line_height : 0;
const int content_top = (logical_height - imageHeight - gap - text_height) / 2;
const int image_left = (logical_width - imageWidth) / 2;
const int text_top = content_top + imageHeight + gap;
std::vector<uint8_t> glyph_bitmap;
for (int band_top = 0; band_top < panelHeight; band_top += bandHeight) {
@ -204,14 +238,11 @@ void BootScreen::show(const std::string& logoPath, const std::vector<std::string
}
};
for (unsigned y = 0; y < logo.height; y++) {
for (unsigned x = 0; x < logo.width; x++) {
const unsigned char* pixel = logo.rgba + (static_cast<size_t>(y) * logo.width + x) * 4;
const uint32_t alpha = pixel[3];
if (alpha != 0) {
// Blended onto the black background
const uint16_t color = toRgb565(pixel[0] * alpha / 255, pixel[1] * alpha / 255, pixel[2] * alpha / 255);
plot(logo_left + static_cast<int>(x), content_top + static_cast<int>(y), color, PIXEL_BUFFER_CONVERSION_LUMA_THRESHOLD);
for (int y = 0; y < imageHeight; y++) {
for (int x = 0; x < imageWidth; x++) {
uint16_t color;
if (imagePixel(x, y, color)) {
plot(image_left + x, content_top + y, color, PIXEL_BUFFER_CONVERSION_LUMA_THRESHOLD);
}
}
}

View File

@ -1,26 +1,15 @@
#ifdef ESP_PLATFORM
#include "Tactility/PanicHandler.h"
#include <Tactility/app/crashdiagnostics/CrashDiagnostics.h>
#include <Tactility/PanicHandler.h>
#include <Tactility/app/boot/BootScreen.h>
#include <Tactility/app/crashdiagnostics/QrHelpers.h>
#include <Tactility/app/crashdiagnostics/QrUrl.h>
#include <Tactility/app/launcher/Launcher.h>
#include <Tactility/file/File.h>
#include <Tactility/lvgl/Statusbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/start.h>
#include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl.h>
#include <qrcode.h>
#include <tactility/check.h>
#include <tactility/drivers/pointer.h>
#include <tactility/delay.h>
#include <tactility/log.h>
#include <tactility/paths.h>
@ -32,32 +21,19 @@
#include <sdkconfig.h>
#include <algorithm>
#include <iomanip>
#include <memory>
#include <new>
#include <sstream>
#include <string>
#include <vector>
namespace tt::app::crashdiagnostics {
constexpr auto* TAG = "CrashDiagnostics";
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
// Set when widget creation hit an unrecoverable error (e.g. the QR code doesn't fit on
// screen) - appMain() skips the event loop and closes immediately without ever starting
// the launcher, matching the old model's stop()-without-launcher-start() error paths.
bool hasFatalError = false;
// Set by onContinuePressed() right before it emits APP_EVENT_CLOSE - read by appMain()
// after its own thread finishes cleanup, to decide whether to start the launcher
// afterwards (matches the old model's onContinuePressed(): stop() then launcher::start()).
bool continuePressed = false;
};
const char* crashCauseToString(CrashCause cause) {
switch (cause) {
case CrashCause::Debug: return "Debug";
@ -117,175 +93,73 @@ void writeCrashLogFile(const CrashData& crashData) {
}
}
void onContinuePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->continuePressed = true;
app_event_emit_close(ctx->appInstanceId);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
auto* display = lv_obj_get_display(parent);
int32_t parent_height = lv_display_get_vertical_resolution(display) - lvgl::statusbar_get_height();
lv_obj_add_event_cb(parent, onContinuePressed, LV_EVENT_SHORT_CLICKED, ctx);
auto* top_label = lv_label_create(parent);
lv_label_set_text(top_label, "Oops! We've crashed ..."); // TODO: Funny messages
lv_obj_align(top_label, LV_ALIGN_TOP_MID, 0, 2);
auto* bottom_label = lv_label_create(parent);
if (device_has_active_by_type(&POINTER_TYPE)) {
lv_label_set_text(bottom_label, "Tap screen to continue");
void waitForInputOrForever(bool hasInput) {
if (hasInput) {
boot::waitForInput();
} else {
lv_label_set_text(bottom_label, "Reboot device to continue");
// Without input to continue with, the device has to be restarted
while (true) {
delay_millis(1000);
}
}
}
lv_obj_align(bottom_label, LV_ALIGN_BOTTOM_MID, 0, -2);
} // namespace
void showCrashScreen(boot::BootScreen& screen) {
const auto& crash_data = getRtcCrashData();
writeCrashLogFile(crash_data);
std::string url = getUrlFromCrashData(crash_data);
std::string prompt = boot::getInputPrompt("continue");
const bool has_input = !prompt.empty();
if (!has_input) {
prompt = "Restart device";
}
// Without a callstack, there's nothing worth reporting through the QR code
if (crash_data.callstackLength == 0) {
std::vector<std::string> lines = { "Oops! We've crashed ..."};
if (crash_data.cause != CrashCause::Unknown) {
lines.push_back(std::string("Only the cause is known: ") + crashCauseToString(crash_data.cause));
}
lines.push_back(prompt);
screen.show("", lines);
waitForInputOrForever(has_input);
return;
}
const std::vector<std::string> lines = { "Oops! We've crashed ...", prompt };
// The QR code links to a page that shows the crash details
const std::string url = getUrlFromCrashData(crash_data);
LOG_I(TAG, "%s", url.c_str());
size_t url_length = url.length();
int qr_version;
if (!getQrVersionForBinaryDataLength(url_length, qr_version)) {
std::unique_ptr<uint8_t[]> qr_buffer;
QRCode qr_code;
bool has_qr_code = false;
if (!getQrVersionForBinaryDataLength(url.length(), qr_version)) {
LOG_E(TAG, "QR is too large");
ctx->hasFatalError = true;
return;
}
LOG_I(TAG, "QR version %d (length: %d)", qr_version, (int)url_length);
auto qrcodeData = std::make_shared<uint8_t[]>(qrcode_getBufferSize(qr_version));
if (qrcodeData == nullptr) {
} else {
qr_buffer.reset(new (std::nothrow) uint8_t[qrcode_getBufferSize(qr_version)]);
if (qr_buffer == nullptr) {
LOG_E(TAG, "Failed to allocate QR buffer");
ctx->hasFatalError = true;
return;
}
QRCode qrcode;
LOG_I(TAG, "QR init text");
if (qrcode_initText(&qrcode, qrcodeData.get(), qr_version, ECC_LOW, url.c_str()) != 0) {
} else if (qrcode_initText(&qr_code, qr_buffer.get(), qr_version, ECC_LOW, url.c_str()) != 0) {
LOG_E(TAG, "QR init text failed");
ctx->hasFatalError = true;
return;
}
LOG_I(TAG, "QR size: %d", qrcode.size);
// Calculate QR dot size
int32_t top_label_height = lv_obj_get_height(top_label) + 2;
int32_t bottom_label_height = lv_obj_get_height(bottom_label) + 2;
LOG_I(TAG, "Create canvas");
int32_t available_height = parent_height - top_label_height - bottom_label_height;
int32_t available_width = lv_display_get_horizontal_resolution(display);
int32_t smallest_size = std::min(available_height, available_width);
// Target ~60% of the available space so the code scales with screen size but keeps a margin
// from the labels/screen edges.
int32_t target_size = smallest_size * 6 / 10;
int32_t pixel_size = std::max<int32_t>(1, target_size / qrcode.size);
if (pixel_size * qrcode.size > smallest_size) {
LOG_E(TAG, "QR code won't fit screen");
ctx->hasFatalError = true;
return;
}
auto* canvas = lv_canvas_create(parent);
lv_obj_set_size(canvas, pixel_size * qrcode.size, pixel_size * qrcode.size);
lv_obj_align(canvas, LV_ALIGN_CENTER, 0, 0);
lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_COVER);
lv_obj_set_content_height(canvas, qrcode.size * pixel_size);
lv_obj_set_content_width(canvas, qrcode.size * pixel_size);
LOG_I(TAG, "Create draw buffer");
auto* draw_buf = lv_draw_buf_create(pixel_size * qrcode.size, pixel_size * qrcode.size, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO);
if (draw_buf == nullptr) {
LOG_E(TAG, "Failed to allocate draw buffer");
ctx->hasFatalError = true;
return;
}
lv_canvas_set_draw_buf(canvas, draw_buf);
for (uint8_t y = 0; y < qrcode.size; y++) {
for (uint8_t x = 0; x < qrcode.size; x++) {
bool colored = qrcode_getModule(&qrcode, x, y);
auto color = colored ? lv_color_white() : lv_color_black();
int32_t pos_x = x * pixel_size;
int32_t pos_y = y * pixel_size;
for (int px = 0; px < pixel_size; px++) {
for (int py = 0; py < pixel_size; py++) {
lv_canvas_set_px(canvas, pos_x + px, pos_y + py, color, LV_OPA_COVER);
}
}
} else {
has_qr_code = true;
}
}
if (has_qr_code) {
screen.showQrCode(qr_code.size, [&qr_code](int x, int y) {
return qrcode_getModule(&qr_code, static_cast<uint8_t>(x), static_cast<uint8_t>(y));
}, lines);
} else {
screen.show("", lines);
}
int32_t appMain(int argc, char* argv[]) {
uint32_t appInstanceId = app_scheduler_current_app_id();
Context ctx {};
ctx.appInstanceId = appInstanceId;
writeCrashLogFile(getRtcCrashData());
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
if (!ctx.hasFatalError) {
bool shouldClose = false;
while (!shouldClose) {
task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY);
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
switch (event.type) {
case APP_EVENT_CLOSE:
shouldClose = true;
break;
default:
break;
}
if (shouldClose) break;
}
}
waitForInputOrForever(has_input);
}
window_manager_remove(window);
check(app_event_unsubscribe(&sub) == ERROR_NONE);
task_event_group_destruct(&event_group);
bool continuePressed = ctx.continuePressed;
if (continuePressed) {
launcher::start();
}
return 0;
}
} // namespace
void start() {
uint32_t instanceId = 0;
AppStartContext context = app_start_context_for_manifest(&manifest);
app_start_with_context(&context, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "tactility.crashdiagnostics",
.name = "Crash Diagnostics",
.category = APP_CATEGORY_SYSTEM,
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
.stack = {}
};
} // namespace
#endif

View File

@ -221,13 +221,4 @@ extern const ::AppManifest manifest = {
.stack = { .depth = 5120, .desired_memory_capability = 0 }
};
// Kept for Tactility/Private/Tactility/app/launcher/Launcher.h's existing declaration (still
// used by the old, unconverted CrashDiagnostics app to return to the launcher after a crash).
uint32_t start() {
uint32_t instance_id = 0;
AppStartContext context = app_start_context_for_manifest(&manifest);
app_start_with_context(&context, &instance_id);
return instance_id;
}
} // namespace