feat(l2): screenshots over USB (RGB565); USB tools moved to the Solo site

- CMD_GET_SCREENSHOT on colour screens: display type 2 (RGB565 LE),
  32-bit payload size; ui-lvgl captureFrame() shared with the SD BMP
- ENABLE_SCREENSHOT in the L2 build; tools/screenshot.py decodes type 2
- docs and READMEs point to solo.marekzegarek.com/#pc-tools
- USB popup: "Keep card" instead of "Charge only" (only the card is lent;
  charging and USB serial work either way)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-29 12:27:59 +02:00
co-authored by Claude Opus 5.5
parent 4ab9d949ec
commit e4d24650c5
11 changed files with 83 additions and 40 deletions
+27 -11
View File
@@ -111,6 +111,7 @@
#define RESP_CODE_DEFAULT_FLOOD_SCOPE 28
#ifdef ENABLE_SCREENSHOT
#define RESP_CODE_SCREENSHOT 29 // Response with screenshot data
#define SCREENSHOT_TYPE_RGB565 2 // display_type of a colour screen's frame
#endif
#define MAX_CHANNEL_DATA_LENGTH (MAX_FRAME_SIZE - 9)
@@ -3139,6 +3140,17 @@ void MyMesh::handleCmdFrame(size_t len) {
void MyMesh::handleScreenshotRequest() {
#ifdef DISPLAY_CLASS
extern UITask ui_task; // main.cpp -- the Listener may be the UI Core, not UITask
#ifdef UI_SCREENSHOT_RGB565
// A colour UI renders a frame for it (RGB565, display type 2).
int w, h;
uint16_t* px = ui_task.captureFrame(w, h);
if (!px) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
return;
}
sendScreenshotResponse(SCREENSHOT_TYPE_RGB565, 0, (uint16_t)w, (uint16_t)h, (const uint8_t*)px, (uint32_t)w * h * 2);
free(px);
#else
if (!ui_task.hasDisplay()) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
return;
@@ -3154,19 +3166,24 @@ void MyMesh::handleScreenshotRequest() {
uint16_t bufferSize = display->getBufferSize();
if (buffer && bufferSize > 0) {
sendScreenshotResponse(display, buffer, bufferSize);
sendScreenshotResponse(display->getDisplayType(), display->screenshotRotation(),
(uint16_t)display->screenshotWidth(), (uint16_t)display->screenshotHeight(),
buffer, bufferSize);
} else {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
}
#endif
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
}
void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize) {
void MyMesh::sendScreenshotResponse(uint8_t displayType, uint8_t rotation, uint16_t width, uint16_t height,
const uint8_t* buffer, uint32_t bufferSize) {
// Frame format (11-byte header, little-endian multi-byte fields):
// [0] resp_code = RESP_CODE_SCREENSHOT
// [1] display_type (0=OLED page-based, 1=e-ink row-major MSB-first 1=white)
// [1] display_type (0=OLED page-based, 1=e-ink row-major MSB-first 1=white,
// 2=RGB565 little-endian, row-major -- colour screens)
// [2] rotation (0-3, GxEPD2/Adafruit_GFX value; only meaningful for e-ink)
// [3..4] width (uint16 LE — GxEPD2-reported visible width)
// [5..6] height (uint16 LE — GxEPD2-reported visible height)
@@ -3174,23 +3191,22 @@ void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffe
// [9..10] total_chunks (uint16 LE)
// [11..] chunk data
const int HEADER_SIZE = 11;
const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE;
const uint16_t width = (uint16_t)display->screenshotWidth();
const uint16_t height = (uint16_t)display->screenshotHeight();
const uint16_t totalChunks = (bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME;
const uint32_t MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE;
const uint16_t totalChunks = (uint16_t)((bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME);
for (uint16_t chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) {
int i = 0;
out_frame[i++] = RESP_CODE_SCREENSHOT;
out_frame[i++] = display->getDisplayType();
out_frame[i++] = display->screenshotRotation();
out_frame[i++] = displayType;
out_frame[i++] = rotation;
out_frame[i++] = width & 0xFF; out_frame[i++] = width >> 8;
out_frame[i++] = height & 0xFF; out_frame[i++] = height >> 8;
out_frame[i++] = chunkIdx & 0xFF; out_frame[i++] = chunkIdx >> 8;
out_frame[i++] = totalChunks & 0xFF; out_frame[i++] = totalChunks >> 8;
int chunkSize = min(MAX_DATA_PER_FRAME, (int)bufferSize - chunkIdx * MAX_DATA_PER_FRAME);
memcpy(&out_frame[i], buffer + chunkIdx * MAX_DATA_PER_FRAME, chunkSize);
const uint32_t offset = (uint32_t)chunkIdx * MAX_DATA_PER_FRAME;
const uint32_t chunkSize = min(MAX_DATA_PER_FRAME, bufferSize - offset);
memcpy(&out_frame[i], buffer + offset, chunkSize);
i += chunkSize;
_serial->writeFrame(out_frame, i);
+2 -1
View File
@@ -579,7 +579,8 @@ private:
bool isValidClientRepeatFreq(uint32_t f) const;
#ifdef ENABLE_SCREENSHOT
void handleScreenshotRequest();
void sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize);
void sendScreenshotResponse(uint8_t displayType, uint8_t rotation, uint16_t width, uint16_t height,
const uint8_t* buffer, uint32_t bufferSize);
#endif
// helpers, short-cuts
@@ -428,8 +428,8 @@ void UITask::storageFormatTap(bool go) {
}
// ── The card as a USB drive ──
// Plugged into a computer, the device asks: charge only, or lend it the SD
// card. Lent, a panel stays up over every screen until the computer ejects
// Plugged into a computer, the device asks whether to lend it the SD card
// (only the card: charging and USB serial carry on either way). Lent, a panel stays up over every screen until the computer ejects
// the drive (or the cable comes out); then the device restarts to reload what
// it keeps from the card. lvport's usbDrive*() do the USB side.
namespace usbview {
@@ -462,7 +462,7 @@ static void usbPanel(const char* title, const char* text, bool buttons) {
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(i ? theme::ACCENT : theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, onUsbTap, LV_EVENT_CLICKED, i ? (void*)1 : nullptr);
lv_obj_center(label(b, i ? "USB drive" : "Charge only", THEME_FONT_BODY, i ? theme::BG : theme::TEXT));
lv_obj_center(label(b, i ? "USB drive" : "Keep card", THEME_FONT_BODY, i ? theme::BG : theme::TEXT));
}
}
@@ -491,8 +491,8 @@ void UITask::usbPoll() {
if (host) { // just plugged into a computer
wake();
usbPanel("Connected to a computer",
"Charge only, or use the SD card as a USB drive? As a drive, the device can't use the card "
"(maps, message history) until you eject it on the computer.", true);
"Lend the SD card to the computer as a USB drive? Until you eject it there, the device "
"can't use the card (maps, message history). Charging and USB work either way.", true);
} else if (s_overlay) { // unplugged before choosing
lv_obj_delete(s_overlay);
s_overlay = nullptr;
+21 -14
View File
@@ -1558,11 +1558,26 @@ static void restartScreen(const char* why) {
}
// The screen as it is, toasts and popups included, re-rendered once into a
// buffer the flush callback fills (lvport::shotCopy) and written to the card
// as a 24-bit BMP: /sdcard/screenshots/scr_NNNN.bmp.
void UITask::takeScreenshot() {
// buffer the flush callback fills (lvport::shotCopy).
uint16_t* UITask::captureFrame(int& w, int& h) {
lv_display_t* d = lv_display_get_default();
const int32_t W = lv_display_get_horizontal_resolution(d), H = lv_display_get_vertical_resolution(d);
w = lv_display_get_horizontal_resolution(d);
h = lv_display_get_vertical_resolution(d);
#ifdef ESP32
uint16_t* px = (uint16_t*)heap_caps_malloc(w * h * 2, MALLOC_CAP_SPIRAM);
#else
uint16_t* px = (uint16_t*)malloc(w * h * 2);
#endif
if (!px) return nullptr;
lvport::s_shot = px;
lv_obj_invalidate(lv_screen_active()); // the whole display, every layer
lv_refr_now(d);
lvport::s_shot = nullptr;
return px;
}
// The screen, written to the card as a 24-bit BMP: /sdcard/screenshots/scr_NNNN.bmp.
void UITask::takeScreenshot() {
if (!lvport::mountStorage()) { showToast("No SD card"); return; }
mkdir("/sdcard", 0777); // the sim's MEMFS may not have it yet (the card: fails, harmless)
mkdir("/sdcard/screenshots", 0777);
@@ -1574,18 +1589,10 @@ void UITask::takeScreenshot() {
if (stat(path, &st) != 0) break;
}
if (n > 9999) { showToast("Screenshots folder full"); return; }
#ifdef ESP32
uint16_t* px = (uint16_t*)heap_caps_malloc(W * H * 2, MALLOC_CAP_SPIRAM);
#else
uint16_t* px = (uint16_t*)malloc(W * H * 2);
#endif
int W, H;
uint16_t* px = captureFrame(W, H);
if (!px) { showToast("Out of memory"); return; }
lvport::s_shot = px;
lv_obj_invalidate(lv_screen_active()); // the whole display, every layer
lv_refr_now(d);
lvport::s_shot = nullptr;
bool ok = false;
if (FILE* f = fopen(path, "wb")) {
const uint32_t row = (uint32_t)W * 3, size = 54 + row * H; // W*3 is a multiple of 4 at 320
@@ -33,6 +33,11 @@ public:
void notify(UIEventType t = UIEventType::none) override;
void shutdown(bool restart = false) override;
MyMesh::Listener* meshListener() override;
// The screen as it is now: RGB565 (native, little-endian), row-major, w x h.
// For CMD_GET_SCREENSHOT (MyMesh, -D ENABLE_SCREENSHOT, display type 2);
// nullptr without the memory for it. The caller frees it with free().
uint16_t* captureFrame(int& w, int& h);
#define UI_SCREENSHOT_RGB565 1
// UiCoreHost
bool isViewingChannel(uint8_t channel_idx) override;