diff --git a/README.md b/README.md index 10c818d1..6d5f4d55 100644 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/docs/solo/hardware.md b/docs/solo/hardware.md index 9e75e2c7..9c17bc2b 100644 --- a/docs/solo/hardware.md +++ b/docs/solo/hardware.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 diff --git a/docs/solo/navigation.md b/docs/solo/navigation.md index e9e97447..cc317145 100644 --- a/docs/solo/navigation.md +++ b/docs/solo/navigation.md @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f8c9bb5a..37847fab 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 9254efd3..94bfc09a 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/StorageScreen.h b/examples/companion_radio/ui-lvgl/StorageScreen.h index 3420fe4c..86a35883 100644 --- a/examples/companion_radio/ui-lvgl/StorageScreen.h +++ b/examples/companion_radio/ui-lvgl/StorageScreen.h @@ -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; diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 9c921697..7adeb71e 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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 diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 341d7cbe..40ff60ad 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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; diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index e22c6c2b..fd6aa53f 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -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} + + diff --git a/tools/README.md b/tools/README.md index 5cc10933..33b5546e 100644 --- a/tools/README.md +++ b/tools/README.md @@ -13,9 +13,9 @@ Python dependencies are managed with [uv](https://docs.astral.sh/uv/) — run th scripts with `uv run tools/