mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 02:11:57 +00:00
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:
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user