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
+1 -1
View File
@@ -51,7 +51,7 @@ Heltec V3/V4 need [a keyboard or joystick wired up](./docs/solo/hardware.md#wiri
[Documentation](./docs/solo/README.md) — getting started, messages, navigation, tools, settings, hardware and developer guides.
**Solo Tools** — [a web app](https://marekzegare4.github.io/Solo-tools/) (Chromium, Web Serial) that takes screenshots and exports the GPS trail as GPX over USB; the same as local scripts in [tools/](./tools/README.md).
**USB tools** — [on the Solo site](https://solo.marekzegarek.com/#pc-tools) (Chrome or Edge, Web Serial): screenshots of the device's screen and the GPS trail as GPX over USB, plus a preview of any GPX file. The same as the local scripts in [tools/](./tools/README.md).
---
+3 -2
View File
@@ -84,8 +84,9 @@ spare the panel.
- **SD card**: holds the message history, maps, GPX trails and live map tiles.
Settings › Storage shows what takes the space, how many messages each
conversation keeps, and deletes the history.
- **USB drive**: plugged into a computer, the device asks whether to only
charge or to lend the computer the SD card. While lent, the device can't use
- **USB drive**: plugged into a computer, the device asks whether to keep the
SD card or lend it to the computer. Charging and the USB connection (the
site's USB tools, the app) work either way. While lent, the device can't use
the card; eject it on the computer and the device restarts. With a screen
PIN set, it doesn't ask until the screen is unlocked.
- **WiFi**: used only for map downloads, live tiles and updates, and off the
+1 -1
View File
@@ -29,7 +29,7 @@ to keep it through a reboot.
### GPX export
Connect USB, open [Solo Tools](https://marekzegare4.github.io/Solo-tools/) in
Connect USB, open the [USB tools](https://solo.marekzegarek.com/#pc-tools) on the Solo site in
Chrome or Edge and click **Connect device**, then on the device choose
**Trail file › Export**. The GPX includes your waypoints. Without a browser,
`uv run tools/trail_export.py` does the same. Disconnect the phone app first if
+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;
+1
View File
@@ -77,6 +77,7 @@ build_flags =
-D HIST_ARCHIVE ; message history also on the SD card (ui-lvgl/HistoryStore.h)
-D GPS_SKYVIEW ; satellites / signal / DOPs for the GPS screen (helpers/sensors/GpsSky.h)
-D ESP32_LITTLEFS ; internal flash as LittleFS, not SPIFFS (helpers/esp32/InternalFS.h)
-D ENABLE_SCREENSHOT ; the screen over USB for the site's tools (RGB565, UITask::captureFrame)
build_src_filter = ${Wio_Tracker_L2.build_src_filter}
+<helpers/esp32/*.cpp>
+<helpers/ui/LGFXDisplay.cpp>
+5 -3
View File
@@ -13,9 +13,9 @@ Python dependencies are managed with [uv](https://docs.astral.sh/uv/) — run th
scripts with `uv run tools/<script>.py` from the repository root.
> [!TIP]
> The hosted [Solo Tools Web App](https://marekzegare4.github.io/Solo-tools/)
> does both screenshots and GPX export with nothing to install, and is the
> easiest option for most people. The scripts here are the offline equivalents.
> The [USB tools on the Solo site](https://solo.marekzegarek.com/#pc-tools) do both screenshots and GPX
> export with nothing to install (Chrome or Edge), and are the easiest option
> for most people. The scripts here are the offline equivalents.
> [!IMPORTANT]
> USB serial is suspended while a BLE connection is active, so disconnect the
@@ -57,6 +57,8 @@ PLATFORMIO_BUILD_FLAGS="-D ENABLE_SCREENSHOT" pio run -e <env> -t upload
- The device replies with `RESP_CODE_SCREENSHOT` (29) carrying the framebuffer
- The framebuffer arrives in chunks (a 128 × 64 display is 1024 bytes, split
across several frames), which the tool reassembles into a PNG
- A colour screen (Wio Tracker L2) sends a freshly rendered frame as RGB565
(display type 2): 320 × 240 is 150 KB, about a second over USB
---
+12 -2
View File
@@ -30,6 +30,7 @@ RESP_CODE_ERR = 1
# display_type values (byte [5] in response frame)
DISPLAY_TYPE_OLED = 0 # page-based, column-major (SH1106/SSD1306)
DISPLAY_TYPE_EINK = 1 # row-major, MSB-first, 1=white/0=black (GxEPD2)
DISPLAY_TYPE_RGB565 = 2 # row-major, 16-bit little-endian (colour screens, e.g. Wio Tracker L2)
# No panel-specific constants needed — the firmware now sends GxEPD2's own reported
# dimensions (which use WIDTH_VISIBLE, not the full physical WIDTH), so the header
@@ -114,6 +115,8 @@ def _u16le(buf, off):
def _expected_buffer_size(width, height, display_type):
"""Bytes the firmware should send for given dims+type."""
if display_type == DISPLAY_TYPE_RGB565:
return width * height * 2
if display_type == DISPLAY_TYPE_EINK:
# GxEPD2 buffer is in physical panel coordinates: row-major,
# stride = ceil(WIDTH_VISIBLE / 8) bytes, HEIGHT rows.
@@ -285,9 +288,16 @@ def eink_buffer_to_image(buffer, log_width, log_height, rotation):
return image
def rgb565_buffer_to_image(buffer, width, height):
"""Colour screen: RGB565 little-endian, row-major."""
return Image.frombuffer("RGB", (width, height), bytes(buffer), "raw", "BGR;16", 0, 1)
def buffer_to_image(buffer, width, height, display_type, rotation=0, scale=1):
"""Convert framebuffer to PIL Image, optionally upscaled."""
if display_type == DISPLAY_TYPE_EINK:
if display_type == DISPLAY_TYPE_RGB565:
image_rgb = rgb565_buffer_to_image(buffer, width, height)
elif display_type == DISPLAY_TYPE_EINK:
image_rgb = eink_buffer_to_image(buffer, width, height, rotation)
else:
image_rgb = oled_buffer_to_image(buffer, width, height)
@@ -357,7 +367,7 @@ def main():
result = receive_screenshot(ser)
if result:
buffer_data, width, height, display_type, disp_rotation = result
type_str = "e-ink" if display_type == DISPLAY_TYPE_EINK else "OLED"
type_str = {DISPLAY_TYPE_EINK: "e-ink", DISPLAY_TYPE_RGB565: "colour"}.get(display_type, "OLED")
print(
f"Received framebuffer: {width}x{height} {type_str} rot={disp_rotation}, {len(buffer_data)} bytes"
)