From 7d9d5f847d9b904276c3dccbfe7a0733a4c566ab Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 01:03:47 +0200 Subject: [PATCH 01/11] feat(screenshot): extend e-ink screenshot support via GxEPD2 patch - Add lib/GxEPD2-patch/src/GxEPD2_BW.h: local patched copy of GxEPD2_BW.h that exposes getBuffer()/getBufferSize() under ENABLE_SCREENSHOT guard. Include guard (_GxEPD2_BW_H_) prevents double-inclusion of the installed library version. - GxEPDDisplay.h: use patched header via relative include when ENABLE_SCREENSHOT so the patch takes precedence over the installed lib (PlatformIO adds library paths before -I build_flags). - DisplayDriver.h: add virtual getBuffer()/getBufferSize()/getDisplayType() defaults (nullptr/0/0) under ENABLE_SCREENSHOT. - SH1106Display.h / SSD1306Display.h / GxEPDDisplay.h: add concrete overrides; getDisplayType() returns 0 (OLED) or 1 (e-ink). - MyMesh.cpp/h: replace fragile C-cast with virtual dispatch in handleScreenshotRequest(); extend 5-byte header to 6 bytes by appending display_type so the host tool can decode the correct pixel layout. - tools/screenshot.py: parse 6-byte header; add eink_buffer_to_image() that decodes the row-major MSB-first GxEPD2 buffer with DISPLAY_ROTATION=1 (phys_x = 127-ly, phys_y = lx); dispatch on display_type. - variants/wio-tracker-l1-eink/platformio.ini: add [env:WioTrackerL1Eink_companion_dual_dev] with ENABLE_SCREENSHOT. - variants/wio-tracker-l1/platformio.ini: unchanged (OLED env already exists). Builds verified: WioTrackerL1Eink_companion_dual_dev SUCCESS, WioTrackerL1Eink_companion_radio_ble SUCCESS (unaffected). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 54 +- examples/companion_radio/MyMesh.h | 2 +- lib/GxEPD2-patch/src/GxEPD2_BW.h | 765 ++++++++++++++++++++ src/helpers/ui/DisplayDriver.h | 8 + src/helpers/ui/GxEPDDisplay.h | 14 + src/helpers/ui/SH1106Display.h | 5 +- src/helpers/ui/SSD1306Display.h | 5 +- tools/screenshot.py | 131 ++-- variants/wio-tracker-l1-eink/platformio.ini | 8 + variants/wio-tracker-l1/platformio.ini | 7 + 10 files changed, 916 insertions(+), 83 deletions(-) create mode 100644 lib/GxEPD2-patch/src/GxEPD2_BW.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 495ecc3b..0a1e5c16 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2077,35 +2077,19 @@ void MyMesh::handleScreenshotRequest() { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); return; } - + DisplayDriver* display = ui_task->getDisplay(); if (!display) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); return; } - - uint8_t* buffer = nullptr; - int bufferSize = 0; - - // Try SH1106Display first (used by Wio L1) - // Use C-style cast since RTTI is disabled (-fno-rtti) - SH1106Display* sh1106 = (SH1106Display*)display; - if (sh1106) { - buffer = sh1106->getBuffer(); - bufferSize = (display->width() * display->height()) / 8; - } else { - // Fallback: try SSD1306Display - SSD1306Display* ssd1306 = (SSD1306Display*)display; - if (ssd1306) { - buffer = ssd1306->getBuffer(); - bufferSize = (display->width() * display->height()) / 8; - } - } - + + const uint8_t* buffer = display->getBuffer(); + uint16_t bufferSize = display->getBufferSize(); + if (buffer && bufferSize > 0) { sendScreenshotResponse(display, buffer, bufferSize); } else { - // No supported display type writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } #else @@ -2113,26 +2097,26 @@ void MyMesh::handleScreenshotRequest() { #endif } -void MyMesh::sendScreenshotResponse(DisplayDriver* display, uint8_t* buffer, int bufferSize) { - // MAX_FRAME_SIZE is 172 bytes, but we need to send 1026 bytes (3 header + 1024 buffer) - // We need to split into multiple frames - // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_index, total_chunks, [chunk_data...] - - const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - 5; // 5 bytes header +void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize) { + // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, total_chunks, display_type, [data] + // display_type: 0=OLED (page-based), 1=e-ink (row-major, MSB-first, 1=white/0=black) + const int HEADER_SIZE = 6; + const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE; int totalChunks = (bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME; - + for (int chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) { int i = 0; out_frame[i++] = RESP_CODE_SCREENSHOT; - out_frame[i++] = display->width(); - out_frame[i++] = display->height(); - out_frame[i++] = chunkIdx; - out_frame[i++] = totalChunks; - - int chunkSize = min(MAX_DATA_PER_FRAME, bufferSize - chunkIdx * MAX_DATA_PER_FRAME); + out_frame[i++] = (uint8_t)display->width(); + out_frame[i++] = (uint8_t)display->height(); + out_frame[i++] = (uint8_t)chunkIdx; + out_frame[i++] = (uint8_t)totalChunks; + out_frame[i++] = display->getDisplayType(); + + 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); i += chunkSize; - + _serial->writeFrame(out_frame, i); } } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index c7aa7366..f6a2eef3 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -229,7 +229,7 @@ private: bool isValidClientRepeatFreq(uint32_t f) const; #ifdef ENABLE_SCREENSHOT void handleScreenshotRequest(); - void sendScreenshotResponse(DisplayDriver* display, uint8_t* buffer, int bufferSize); + void sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize); #endif UITask* getUITask() { return (UITask*)_ui; } diff --git a/lib/GxEPD2-patch/src/GxEPD2_BW.h b/lib/GxEPD2-patch/src/GxEPD2_BW.h new file mode 100644 index 00000000..64f577eb --- /dev/null +++ b/lib/GxEPD2-patch/src/GxEPD2_BW.h @@ -0,0 +1,765 @@ +// Display Library for SPI e-paper panels from Dalian Good Display and boards from Waveshare. +// Requires HW SPI and Adafruit_GFX. Caution: the e-paper panels require 3.3V supply AND data lines! +// +// Display Library based on Demo Example from Good Display: https://www.good-display.com/companyfile/32/ +// +// Author: Jean-Marc Zingg +// +// Version: see library.properties +// +// Library: https://github.com/ZinggJM/GxEPD2 + +#ifndef _GxEPD2_BW_H_ +#define _GxEPD2_BW_H_ + +// uncomment next line to use class GFX of library GFX_Root instead of Adafruit_GFX +//#include + +#ifndef ENABLE_GxEPD2_GFX +// default is off +#define ENABLE_GxEPD2_GFX 0 +#endif + +#if ENABLE_GxEPD2_GFX +#include "GxEPD2_GFX.h" +#define GxEPD2_GFX_BASE_CLASS GxEPD2_GFX +#elif defined(_GFX_H_) +#define GxEPD2_GFX_BASE_CLASS GFX +#else +#include +#define GxEPD2_GFX_BASE_CLASS Adafruit_GFX +#endif + +#include "GxEPD2_EPD.h" + +// for __has_include see https://en.cppreference.com/w/cpp/preprocessor/include +// see also https://gcc.gnu.org/onlinedocs/cpp/_005f_005fhas_005finclude.html +// #if !defined(__has_include) || __has_include("epd/GxEPD2_102.h") is not portable! + +#if defined __has_include +# if __has_include("GxEPD2.h") +# // __has_include can be used +# else +# // __has_include doesn't work for us, include anyway +# undef __has_include +# define __has_include(x) true +# endif +#else +# // no __has_include, include anyway +# define __has_include(x) true +#endif + +#if __has_include("epd/GxEPD2_102.h") +#include "epd/GxEPD2_102.h" +#endif +#if __has_include("epd/GxEPD2_150_BN.h") +#include "epd/GxEPD2_150_BN.h" +#endif +#if __has_include("epd/GxEPD2_154.h") +#include "epd/GxEPD2_154.h" +#endif +#if __has_include("epd/GxEPD2_154_D67.h") +#include "epd/GxEPD2_154_D67.h" +#endif +#if __has_include("epd/GxEPD2_154_T8.h") +#include "epd/GxEPD2_154_T8.h" +#endif +#if __has_include("epd/GxEPD2_154_M09.h") +#include "epd/GxEPD2_154_M09.h" +#endif +#if __has_include("epd/GxEPD2_154_M10.h") +#include "epd/GxEPD2_154_M10.h" +#endif +#if __has_include("gdey/GxEPD2_154_GDEY0154D67.h") +#include "gdey/GxEPD2_154_GDEY0154D67.h" +#endif +#if __has_include("epd/GxEPD2_213.h") +#include "epd/GxEPD2_213.h" +#endif +#if __has_include("epd/GxEPD2_213_B72.h") +#include "epd/GxEPD2_213_B72.h" +#endif +#if __has_include("epd/GxEPD2_213_B73.h") +#include "epd/GxEPD2_213_B73.h" +#endif +#if __has_include("epd/GxEPD2_213_B74.h") +#include "epd/GxEPD2_213_B74.h" +#endif +#if __has_include("epd/GxEPD2_213_flex.h") +#include "epd/GxEPD2_213_flex.h" +#endif +#if __has_include("epd/GxEPD2_213_M21.h") +#include "epd/GxEPD2_213_M21.h" +#endif +#if __has_include("epd/GxEPD2_213_T5D.h") +#include "epd/GxEPD2_213_T5D.h" +#endif +#if __has_include("epd/GxEPD2_213_BN.h") +#include "epd/GxEPD2_213_BN.h" +#endif +#if __has_include("gdey/GxEPD2_213_GDEY0213B74.h") +#include "gdey/GxEPD2_213_GDEY0213B74.h" +#endif +#if __has_include("epd/GxEPD2_260.h") +#include "epd/GxEPD2_260.h" +#endif +#if __has_include("epd/GxEPD2_260_M01.h") +#include "epd/GxEPD2_260_M01.h" +#endif +#if __has_include("epd/GxEPD2_266_BN.h") +#include "epd/GxEPD2_266_BN.h" +#endif +#if __has_include("gdey/GxEPD2_266_GDEY0266T90.h") +#include "gdey/GxEPD2_266_GDEY0266T90.h" +#endif +#if __has_include("epd/GxEPD2_290.h") +#include "epd/GxEPD2_290.h" +#endif +#if __has_include("epd/GxEPD2_290_T5.h") +#include "epd/GxEPD2_290_T5.h" +#endif +#if __has_include("epd/GxEPD2_290_T5D.h") +#include "epd/GxEPD2_290_T5D.h" +#endif +#if __has_include("epd/GxEPD2_290_I6FD.h") +#include "epd/GxEPD2_290_I6FD.h" +#endif +#if __has_include("epd/GxEPD2_290_M06.h") +#include "epd/GxEPD2_290_M06.h" +#endif +#if __has_include("epd/GxEPD2_290_T94.h") +#include "epd/GxEPD2_290_T94.h" +#endif +#if __has_include("gdey/GxEPD2_290_GDEY029T94.h") +#include "gdey/GxEPD2_290_GDEY029T94.h" +#endif +#if __has_include("epd/GxEPD2_290_T94_V2.h") +#include "epd/GxEPD2_290_T94_V2.h" +#endif +#if __has_include("epd/GxEPD2_290_BS.h") +#include "epd/GxEPD2_290_BS.h" +#endif +#if __has_include("gdey/GxEPD2_290_GDEY029T71H.h") +#include "gdey/GxEPD2_290_GDEY029T71H.h" +#endif +#if __has_include("epd/GxEPD2_270.h") +#include "epd/GxEPD2_270.h" +#endif +#if __has_include("gdey/GxEPD2_270_GDEY027T91.h") +#include "gdey/GxEPD2_270_GDEY027T91.h" +#endif +#if __has_include("gdeq/GxEPD2_310_GDEQ031T10.h") +#include "gdeq/GxEPD2_310_GDEQ031T10.h" +#endif +#if __has_include("epd/GxEPD2_371.h") +#include "epd/GxEPD2_371.h" +#endif +#if __has_include("epd/GxEPD2_370_TC1.h") +#include "epd/GxEPD2_370_TC1.h" +#endif +#if __has_include("epd/GxEPD2_420.h") +#include "epd/GxEPD2_420.h" +#endif +#if __has_include("epd/GxEPD2_420_M01.h") +#include "epd/GxEPD2_420_M01.h" +#endif +#if __has_include("gdey/GxEPD2_420_GDEY042T81.h") +#include "gdey/GxEPD2_420_GDEY042T81.h" +#endif +#if __has_include("other/GxEPD2_420_GYE042A87.h") +#include "other/GxEPD2_420_GYE042A87.h" +#endif +#if __has_include("other/GxEPD2_420_SE0420NQ04.h") +#include "other/GxEPD2_420_SE0420NQ04.h" +#endif +#if __has_include("gdeq/GxEPD2_426_GDEQ0426T82.h") +#include "gdeq/GxEPD2_426_GDEQ0426T82.h" +#endif +#if __has_include("gdey/GxEPD2_579_GDEY0579T93.h") +#include "gdey/GxEPD2_579_GDEY0579T93.h" +#endif +#if __has_include("epd/GxEPD2_583.h") +#include "epd/GxEPD2_583.h" +#endif +#if __has_include("epd/GxEPD2_583_T8.h") +#include "epd/GxEPD2_583_T8.h" +#endif +#if __has_include("gdeq/GxEPD2_583_GDEQ0583T31.h") +#include "gdeq/GxEPD2_583_GDEQ0583T31.h" +#endif +#if __has_include("epd/GxEPD2_750.h") +#include "epd/GxEPD2_750.h" +#endif +#if __has_include("epd/GxEPD2_750_T7.h") +#include "epd/GxEPD2_750_T7.h" +#endif +#if __has_include("gdey/GxEPD2_750_GDEY075T7.h") +#include "gdey/GxEPD2_750_GDEY075T7.h" +#endif +#if __has_include("gdem/GxEPD2_1020_GDEM102T91.h") +#include "gdem/GxEPD2_1020_GDEM102T91.h" +#endif +#if __has_include("gdem/GxEPD2_1085_GDEM1085T51.h") +#include "gdem/GxEPD2_1085_GDEM1085T51.h" +#endif +#if __has_include("epd/GxEPD2_1160_T91.h") +#include "epd/GxEPD2_1160_T91.h" +#endif +#if __has_include("gdem/GxEPD2_1330_GDEM133T91.h") +#include "gdem/GxEPD2_1330_GDEM133T91.h" +#endif +#if __has_include("epd/GxEPD2_1248.h") +#include "epd/GxEPD2_1248.h" +#endif +#if __has_include("it8951/GxEPD2_it60.h") +#include "it8951/GxEPD2_it60.h" +#endif +#if __has_include("it8951/GxEPD2_it60_1448x1072.h") +#include "it8951/GxEPD2_it60_1448x1072.h" +#endif +#if __has_include("it8951/GxEPD2_it78_1872x1404.h") +#include "it8951/GxEPD2_it78_1872x1404.h" +#endif +#if __has_include("it8951/GxEPD2_it103_1872x1404.h") +#include "it8951/GxEPD2_it103_1872x1404.h" +#endif + +template +class GxEPD2_BW : public GxEPD2_GFX_BASE_CLASS +{ + public: + GxEPD2_Type epd2; +#ifdef ENABLE_SCREENSHOT + // Expose framebuffer for screenshot capture. + // This file is a patched copy kept in lib/GxEPD2-patch/src/ and tracked by git. + const uint8_t* getBuffer() { return _buffer; } + uint16_t getBufferSize() { return sizeof(_buffer); } +#endif +#if ENABLE_GxEPD2_GFX + GxEPD2_BW(GxEPD2_Type epd2_instance) : GxEPD2_GFX_BASE_CLASS(epd2, GxEPD2_Type::WIDTH_VISIBLE, GxEPD2_Type::HEIGHT), epd2(epd2_instance) +#else + GxEPD2_BW(GxEPD2_Type epd2_instance) : GxEPD2_GFX_BASE_CLASS(GxEPD2_Type::WIDTH_VISIBLE, GxEPD2_Type::HEIGHT), epd2(epd2_instance) +#endif + { + _page_height = page_height; + _pages = (HEIGHT / _page_height) + ((HEIGHT % _page_height) > 0); + _reverse = (epd2_instance.panel == GxEPD2::GDE0213B1); + _mirror = false; + _using_partial_mode = false; + _current_page = 0; + setFullWindow(); + } + + uint16_t pages() + { + return _pages; + } + + uint16_t pageHeight() + { + return _page_height; + } + + bool mirror(bool m) + { + _swap_ (_mirror, m); + return m; + } + + void drawPixel(int16_t x, int16_t y, uint16_t color) + { + if ((x < 0) || (x >= width()) || (y < 0) || (y >= height())) return; + if (_mirror) x = width() - x - 1; + // check rotation, move pixel around if necessary + switch (getRotation()) + { + case 1: + _swap_(x, y); + x = WIDTH - x - 1; + break; + case 2: + x = WIDTH - x - 1; + y = HEIGHT - y - 1; + break; + case 3: + _swap_(x, y); + y = HEIGHT - y - 1; + break; + } + // transpose partial window to 0,0 + x -= _pw_x; + if (!_reverse) y -= _pw_y; + else y = HEIGHT - _pw_y - y - 1; + // clip to (partial) window + if ((x < 0) || (x >= int16_t(_pw_w)) || (y < 0) || (y >= int16_t(_pw_h))) return; + // adjust for current page + y -= _current_page * _page_height; + // check if in current page + if ((y < 0) || (y >= int16_t(_page_height))) return; + uint16_t i = x / 8 + y * (_pw_w / 8); + if (color) + _buffer[i] = (_buffer[i] | (1 << (7 - x % 8))); + else + _buffer[i] = (_buffer[i] & (0xFF ^ (1 << (7 - x % 8)))); + } + + void init(uint32_t serial_diag_bitrate = 0) // = 0 : disabled + { + epd2.init(serial_diag_bitrate); + _using_partial_mode = false; + _current_page = 0; + setFullWindow(); + } + + // init method with additional parameters: + // initial false for re-init after processor deep sleep wake up, if display power supply was kept + // this can be used to avoid the repeated initial full refresh on displays with fast partial update + // NOTE: garbage will result on fast partial update displays, if initial full update is omitted after power loss + // reset_duration = 10 is default; a value of 2 may help with "clever" reset circuit of newer boards from Waveshare + // pulldown_rst_mode true for alternate RST handling to avoid feeding 5V through RST pin + void init(uint32_t serial_diag_bitrate, bool initial, uint16_t reset_duration = 10, bool pulldown_rst_mode = false) + { + epd2.init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode); + _using_partial_mode = false; + _current_page = 0; + setFullWindow(); + } + + // init method with additional parameters: + // SPIClass& spi: either SPI or alternate HW SPI channel + // SPISettings spi_settings: e.g. for higher SPI speed selection + void init(uint32_t serial_diag_bitrate, bool initial, uint16_t reset_duration, bool pulldown_rst_mode, SPIClass& spi, SPISettings spi_settings) + { + epd2.selectSPI(spi, spi_settings); + epd2.init(serial_diag_bitrate, initial, reset_duration, pulldown_rst_mode); + _using_partial_mode = false; + _current_page = 0; + setFullWindow(); + } + + // release SPI and control pins + void end() + { + epd2.end(); + } + + void fillScreen(uint16_t color) // 0x0 black, >0x0 white, to buffer + { + uint8_t data = (color == GxEPD_BLACK) ? 0x00 : 0xFF; + for (uint16_t x = 0; x < sizeof(_buffer); x++) + { + _buffer[x] = data; + } + } + + // display buffer content to screen, useful for full screen buffer + void display(bool partial_update_mode = false) + { + if (partial_update_mode) epd2.writeImage(_buffer, 0, 0, GxEPD2_Type::WIDTH, _page_height); + else epd2.writeImageForFullRefresh(_buffer, 0, 0, GxEPD2_Type::WIDTH, _page_height); + epd2.refresh(partial_update_mode); + if (epd2.hasFastPartialUpdate) + { + epd2.writeImageAgain(_buffer, 0, 0, GxEPD2_Type::WIDTH, _page_height); + } + if (!partial_update_mode) epd2.powerOff(); + } + + // display part of buffer content to screen, useful for full screen buffer + // displayWindow, use parameters according to actual rotation. + // x and w should be multiple of 8, for rotation 0 or 2, + // y and h should be multiple of 8, for rotation 1 or 3, + // else window is increased as needed, + // this is an addressing limitation of the e-paper controllers + void displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) + { + x = gx_uint16_min(x, width()); + y = gx_uint16_min(y, height()); + w = gx_uint16_min(w, width() - x); + h = gx_uint16_min(h, height() - y); + _rotate(x, y, w, h); + uint16_t y_part = _reverse ? HEIGHT - h - y : y; + epd2.writeImagePart(_buffer, x, y_part, GxEPD2_Type::WIDTH, _page_height, x, y_part, w, h); + epd2.refresh(x, y_part, w, h); + if (epd2.hasFastPartialUpdate) + { + epd2.writeImagePartAgain(_buffer, x, y_part, GxEPD2_Type::WIDTH, _page_height, x, y_part, w, h); + } + } + + void setFullWindow() + { + _using_partial_mode = false; + _pw_x = 0; + _pw_y = 0; + _pw_w = GxEPD2_Type::WIDTH; + _pw_h = HEIGHT; + } + + // setPartialWindow, use parameters according to actual rotation. + // x and w should be multiple of 8, for rotation 0 or 2, + // y and h should be multiple of 8, for rotation 1 or 3, + // else window is increased as needed, + // this is an addressing limitation of the e-paper controllers + void setPartialWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) + { + _pw_x = gx_uint16_min(x, width()); + _pw_y = gx_uint16_min(y, height()); + _pw_w = gx_uint16_min(w, width() - _pw_x); + _pw_h = gx_uint16_min(h, height() - _pw_y); + _rotate(_pw_x, _pw_y, _pw_w, _pw_h); + _using_partial_mode = true; + // make _pw_x, _pw_w multiple of 8 + _pw_w += _pw_x % 8; + if (_pw_w % 8 > 0) _pw_w += 8 - _pw_w % 8; + _pw_x -= _pw_x % 8; + if (_reverse) _pw_y = HEIGHT - _pw_h - _pw_y; + } + + void firstPage() + { + fillScreen(GxEPD_WHITE); + _current_page = 0; + _second_phase = false; + } + + bool nextPage() + { + if (1 == _pages) + { + if (_using_partial_mode) + { + epd2.writeImage(_buffer, _pw_x, _pw_y, _pw_w, _pw_h); + epd2.refresh(_pw_x, _pw_y, _pw_w, _pw_h); + if (epd2.hasFastPartialUpdate) + { + epd2.writeImageAgain(_buffer, _pw_x, _pw_y, _pw_w, _pw_h); + //epd2.refresh(_pw_x, _pw_y, _pw_w, _pw_h); // not needed + } + } + else // full update + { + epd2.writeImageForFullRefresh(_buffer, 0, 0, GxEPD2_Type::WIDTH, HEIGHT); + epd2.refresh(false); + if (epd2.hasFastPartialUpdate) + { + epd2.writeImageAgain(_buffer, 0, 0, GxEPD2_Type::WIDTH, HEIGHT); + //epd2.refresh(true); // not needed + } + epd2.powerOff(); + } + return false; + } + uint16_t page_ys = _current_page * _page_height; + if (_using_partial_mode) + { + //Serial.print(" nextPage("); Serial.print(_pw_x); Serial.print(", "); Serial.print(_pw_y); Serial.print(", "); + //Serial.print(_pw_w); Serial.print(", "); Serial.print(_pw_h); Serial.print(") P"); Serial.println(_current_page); + uint16_t page_ye = _current_page < int16_t(_pages - 1) ? page_ys + _page_height : HEIGHT; + uint16_t dest_ys = _pw_y + page_ys; // transposed + uint16_t dest_ye = gx_uint16_min(_pw_y + _pw_h, _pw_y + page_ye); + if (dest_ye > dest_ys) + { + //Serial.print("writeImage("); Serial.print(_pw_x); Serial.print(", "); Serial.print(dest_ys); Serial.print(", "); + //Serial.print(_pw_w); Serial.print(", "); Serial.print(dest_ye - dest_ys); Serial.println(")"); + if (!_second_phase) epd2.writeImage(_buffer, _pw_x, dest_ys, _pw_w, dest_ye - dest_ys); + else epd2.writeImageAgain(_buffer, _pw_x, dest_ys, _pw_w, dest_ye - dest_ys); + } + else + { + //Serial.print("writeImage("); Serial.print(_pw_x); Serial.print(", "); Serial.print(dest_ys); Serial.print(", "); + //Serial.print(_pw_w); Serial.print(", "); Serial.print(dest_ye - dest_ys); Serial.print(") skipped "); + //Serial.print(dest_ys); Serial.print(".."); Serial.println(dest_ye); + } + _current_page++; + if (_current_page == int16_t(_pages)) + { + _current_page = 0; + if (!_second_phase) + { + epd2.refresh(_pw_x, _pw_y, _pw_w, _pw_h); + if (epd2.hasFastPartialUpdate) + { + _second_phase = true; + fillScreen(GxEPD_WHITE); + return true; + } + } + return false; + } + fillScreen(GxEPD_WHITE); + return true; + } + else // full update + { + if (!_second_phase) epd2.writeImageForFullRefresh(_buffer, 0, page_ys, GxEPD2_Type::WIDTH, gx_uint16_min(_page_height, HEIGHT - page_ys)); + else epd2.writeImageAgain(_buffer, 0, page_ys, GxEPD2_Type::WIDTH, gx_uint16_min(_page_height, HEIGHT - page_ys)); + _current_page++; + if (_current_page == int16_t(_pages)) + { + _current_page = 0; + if (epd2.hasFastPartialUpdate) + { + if (!_second_phase) + { + epd2.refresh(false); // full update after first phase + _second_phase = true; + fillScreen(GxEPD_WHITE); + return true; + } + //else epd2.refresh(true); // partial update after second phase + } else epd2.refresh(false); // full update after only phase + epd2.powerOff(); + return false; + } + fillScreen(GxEPD_WHITE); + return true; + } + } + + // GxEPD style paged drawing; drawCallback() is called as many times as needed + void drawPaged(void (*drawCallback)(const void*), const void* pv) + { + if (1 == _pages) + { + fillScreen(GxEPD_WHITE); + drawCallback(pv); + if (_using_partial_mode) + { + epd2.writeImage(_buffer, _pw_x, _pw_y, _pw_w, _pw_h); + epd2.refresh(_pw_x, _pw_y, _pw_w, _pw_h); + if (epd2.hasFastPartialUpdate) + { + epd2.writeImageAgain(_buffer, _pw_x, _pw_y, _pw_w, _pw_h); + //epd2.refresh(_pw_x, _pw_y, _pw_w, _pw_h); // not needed + } + } + else // full update + { + epd2.writeImageForFullRefresh(_buffer, 0, 0, GxEPD2_Type::WIDTH, HEIGHT); + epd2.refresh(false); + if (epd2.hasFastPartialUpdate) + { + epd2.writeImageAgain(_buffer, 0, 0, GxEPD2_Type::WIDTH, HEIGHT); + //epd2.refresh(true); // not needed + epd2.powerOff(); + } + } + return; + } + if (_using_partial_mode) + { + for (uint16_t phase = 1; phase <= 2; phase++) + { + for (_current_page = 0; _current_page < _pages; _current_page++) + { + uint16_t page_ys = _current_page * _page_height; + uint16_t page_ye = _current_page < (_pages - 1) ? page_ys + _page_height : HEIGHT; + uint16_t dest_ys = _pw_y + page_ys; // transposed + uint16_t dest_ye = gx_uint16_min(_pw_y + _pw_h, _pw_y + page_ye); + if (dest_ye > dest_ys) + { + fillScreen(GxEPD_WHITE); + drawCallback(pv); + if (phase == 1) epd2.writeImage(_buffer, _pw_x, dest_ys, _pw_w, dest_ye - dest_ys); + else epd2.writeImageAgain(_buffer, _pw_x, dest_ys, _pw_w, dest_ye - dest_ys); + } + } + epd2.refresh(_pw_x, _pw_y, _pw_w, _pw_h); + if (!epd2.hasFastPartialUpdate) break; + // else make both controller buffers have equal content + } + } + else // full update + { + for (_current_page = 0; _current_page < _pages; _current_page++) + { + uint16_t page_ys = _current_page * _page_height; + fillScreen(GxEPD_WHITE); + drawCallback(pv); + epd2.writeImageForFullRefresh(_buffer, 0, page_ys, GxEPD2_Type::WIDTH, gx_uint16_min(_page_height, HEIGHT - page_ys)); + } + epd2.refresh(false); // full update after first phase + if (epd2.hasFastPartialUpdate) + { + // make both controller buffers have equal content + for (_current_page = 0; _current_page < _pages; _current_page++) + { + uint16_t page_ys = _current_page * _page_height; + fillScreen(GxEPD_WHITE); + drawCallback(pv); + epd2.writeImageAgain(_buffer, 0, page_ys, GxEPD2_Type::WIDTH, gx_uint16_min(_page_height, HEIGHT - page_ys)); + } + //epd2.refresh(true); // partial update after second phase // not needed + } + epd2.powerOff(); + } + _current_page = 0; + } + + void drawInvertedBitmap(int16_t x, int16_t y, const uint8_t bitmap[], int16_t w, int16_t h, uint16_t color) + { + // taken from Adafruit_GFX.cpp, modified + int16_t byteWidth = (w + 7) / 8; // Bitmap scanline pad = whole byte + uint8_t byte = 0; + for (int16_t j = 0; j < h; j++) + { + for (int16_t i = 0; i < w; i++ ) + { + if (i & 7) byte <<= 1; + else + { +#if defined(__AVR) || defined(ESP8266) || defined(ESP32) + byte = pgm_read_byte(&bitmap[j * byteWidth + i / 8]); +#else + byte = bitmap[j * byteWidth + i / 8]; +#endif + } + if (!(byte & 0x80)) + { + drawPixel(x + i, y + j, color); + } + } + } + } + + // Support for Bitmaps (Sprites) to Controller Buffer and to Screen + void clearScreen(uint8_t value = 0xFF) // init controller memory and screen (default white) + { + epd2.clearScreen(value); + } + void writeScreenBuffer(uint8_t value = 0xFF) // init controller memory (default white) + { + epd2.writeScreenBuffer(value); + } + // write to controller memory, without screen refresh; x and w should be multiple of 8 + void writeImage(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h, bool invert = false, bool mirror_y = false, bool pgm = false) + { + epd2.writeImage(bitmap, x, y, w, h, invert, mirror_y, pgm); + } + void writeImagePart(const uint8_t bitmap[], int16_t x_part, int16_t y_part, int16_t w_bitmap, int16_t h_bitmap, + int16_t x, int16_t y, int16_t w, int16_t h, bool invert = false, bool mirror_y = false, bool pgm = false) + { + epd2.writeImagePart(bitmap, x_part, y_part, w_bitmap, h_bitmap, x, y, w, h, invert, mirror_y, pgm); + } + void writeImage(const uint8_t* black, const uint8_t* color, int16_t x, int16_t y, int16_t w, int16_t h, bool invert, bool mirror_y, bool pgm) + { + epd2.writeImage(black, color, x, y, w, h, invert, mirror_y, pgm); + } + void writeImage(const uint8_t* black, const uint8_t* color, int16_t x, int16_t y, int16_t w, int16_t h) + { + epd2.writeImage(black, color, x, y, w, h, false, false, false); + } + void writeImagePart(const uint8_t* black, const uint8_t* color, int16_t x_part, int16_t y_part, int16_t w_bitmap, int16_t h_bitmap, + int16_t x, int16_t y, int16_t w, int16_t h, bool invert, bool mirror_y, bool pgm) + { + epd2.writeImagePart(black, color, x_part, y_part, w_bitmap, h_bitmap, x, y, w, h, invert, mirror_y, pgm); + } + void writeImagePart(const uint8_t* black, const uint8_t* color, int16_t x_part, int16_t y_part, int16_t w_bitmap, int16_t h_bitmap, + int16_t x, int16_t y, int16_t w, int16_t h) + { + epd2.writeImagePart(black, color, x_part, y_part, w_bitmap, h_bitmap, x, y, w, h, false, false, false); + } + // write sprite of native data to controller memory, without screen refresh; x and w should be multiple of 8 + void writeNative(const uint8_t* data1, const uint8_t* data2, int16_t x, int16_t y, int16_t w, int16_t h, bool invert, bool mirror_y, bool pgm) + { + epd2.writeNative(data1, data2, x, y, w, h, invert, mirror_y, pgm); + } + // write to controller memory, with screen refresh; x and w should be multiple of 8 + void drawImage(const uint8_t bitmap[], int16_t x, int16_t y, int16_t w, int16_t h, bool invert = false, bool mirror_y = false, bool pgm = false) + { + epd2.drawImage(bitmap, x, y, w, h, invert, mirror_y, pgm); + } + void drawImagePart(const uint8_t bitmap[], int16_t x_part, int16_t y_part, int16_t w_bitmap, int16_t h_bitmap, + int16_t x, int16_t y, int16_t w, int16_t h, bool invert = false, bool mirror_y = false, bool pgm = false) + { + epd2.drawImagePart(bitmap, x_part, y_part, w_bitmap, h_bitmap, x, y, w, h, invert, mirror_y, pgm); + } + void drawImage(const uint8_t* black, const uint8_t* color, int16_t x, int16_t y, int16_t w, int16_t h, bool invert, bool mirror_y, bool pgm) + { + epd2.drawImage(black, color, x, y, w, h, invert, mirror_y, pgm); + } + void drawImage(const uint8_t* black, const uint8_t* color, int16_t x, int16_t y, int16_t w, int16_t h) + { + epd2.drawImage(black, color, x, y, w, h, false, false, false); + } + void drawImagePart(const uint8_t* black, const uint8_t* color, int16_t x_part, int16_t y_part, int16_t w_bitmap, int16_t h_bitmap, + int16_t x, int16_t y, int16_t w, int16_t h, bool invert, bool mirror_y, bool pgm) + { + epd2.drawImagePart(black, color, x_part, y_part, w_bitmap, h_bitmap, x, y, w, h, invert, mirror_y, pgm); + } + void drawImagePart(const uint8_t* black, const uint8_t* color, int16_t x_part, int16_t y_part, int16_t w_bitmap, int16_t h_bitmap, + int16_t x, int16_t y, int16_t w, int16_t h) + { + epd2.drawImagePart(black, color, x_part, y_part, w_bitmap, h_bitmap, x, y, w, h, false, false, false); + } + // write sprite of native data to controller memory, with screen refresh; x and w should be multiple of 8 + void drawNative(const uint8_t* data1, const uint8_t* data2, int16_t x, int16_t y, int16_t w, int16_t h, bool invert, bool mirror_y, bool pgm) + { + epd2.drawNative(data1, data2, x, y, w, h, invert, mirror_y, pgm); + } + void refresh(bool partial_update_mode = false) // screen refresh from controller memory to full screen + { + epd2.refresh(partial_update_mode); + if (!partial_update_mode) epd2.powerOff(); + } + void refresh(int16_t x, int16_t y, int16_t w, int16_t h) // screen refresh from controller memory, partial screen + { + epd2.refresh(x, y, w, h); + } + // turns off generation of panel driving voltages, avoids screen fading over time + void powerOff() + { + epd2.powerOff(); + } + // turns powerOff() and sets controller to deep sleep for minimum power use, ONLY if wakeable by RST (rst >= 0) + void hibernate() + { + epd2.hibernate(); + } + private: + template static inline void + _swap_(T & a, T & b) + { + T t = a; + a = b; + b = t; + }; + static inline uint16_t gx_uint16_min(uint16_t a, uint16_t b) + { + return (a < b ? a : b); + }; + static inline uint16_t gx_uint16_max(uint16_t a, uint16_t b) + { + return (a > b ? a : b); + }; + void _rotate(uint16_t& x, uint16_t& y, uint16_t& w, uint16_t& h) + { + switch (getRotation()) + { + case 1: + _swap_(x, y); + _swap_(w, h); + x = WIDTH - x - w; + break; + case 2: + x = WIDTH - x - w; + y = HEIGHT - y - h; + break; + case 3: + _swap_(x, y); + _swap_(w, h); + y = HEIGHT - y - h; + break; + } + } + private: + uint8_t _buffer[(GxEPD2_Type::WIDTH / 8) * page_height]; + bool _using_partial_mode, _second_phase, _mirror, _reverse; + uint16_t _width_bytes, _pixel_bytes; + int16_t _current_page; + uint16_t _pages, _page_height; + uint16_t _pw_x, _pw_y, _pw_w, _pw_h; +}; + +#endif diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 3eeb1b6b..25a6b871 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -247,4 +247,12 @@ public: virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never) virtual void endFrame() = 0; + +#ifdef ENABLE_SCREENSHOT + // Screenshot support — return raw framebuffer and its size in bytes. + // 0=OLED (page-based, column-major), 1=e-ink (row-major, MSB-first, 1=white/0=black). + virtual const uint8_t* getBuffer() { return nullptr; } + virtual uint16_t getBufferSize() { return 0; } + virtual uint8_t getDisplayType() { return 0; } +#endif }; diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index ea43e153..86439463 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -5,7 +5,15 @@ #define ENABLE_GxEPD2_GFX 0 +// When ENABLE_SCREENSHOT is active, use the patched copy of GxEPD2_BW.h from +// lib/GxEPD2-patch/src/ that exposes getBuffer()/getBufferSize(). The include +// guard (_GxEPD2_BW_H_) then prevents the installed library version from +// being compiled again. Non-screenshot builds use the installed library as-is. +#ifdef ENABLE_SCREENSHOT +#include "../../../lib/GxEPD2-patch/src/GxEPD2_BW.h" +#else #include +#endif #include #include #include @@ -70,6 +78,12 @@ public: display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {} #endif +#ifdef ENABLE_SCREENSHOT + const uint8_t* getBuffer() override { return display.getBuffer(); } + uint16_t getBufferSize() override { return display.getBufferSize(); } + uint8_t getDisplayType() override { return 1; } // 1 = e-ink +#endif + // Line height and approx. char width for each font size: // 1 = FreeSans9pt (lineH=16, charW≈9) // 2 = FreeSansBold12pt (lineH=20, charW≈12) diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 134db133..ef78a9a5 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -60,7 +60,8 @@ public: void endFrame() override; #ifdef ENABLE_SCREENSHOT - // Screenshot support - uint8_t* getBuffer() { return display.getBuffer(); } + const uint8_t* getBuffer() override { return display.getBuffer(); } + uint16_t getBufferSize() override { return (uint16_t)((width() * height()) / 8); } + uint8_t getDisplayType() override { return 0; } #endif }; diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index a5ce696e..f3b66063 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -47,7 +47,8 @@ public: void endFrame() override; #ifdef ENABLE_SCREENSHOT - // Screenshot support - uint8_t* getBuffer() { return display.getBuffer(); } + const uint8_t* getBuffer() override { return display.getBuffer(); } + uint16_t getBufferSize() override { return (uint16_t)((width() * height()) / 8); } + uint8_t getDisplayType() override { return 0; } #endif }; diff --git a/tools/screenshot.py b/tools/screenshot.py index 750488dd..3de1e811 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Screenshot tool for Wio L1 Tracker Pro with SH1106 display. +Screenshot tool for Wio L1 Tracker Pro (OLED SH1106 and e-ink GxEPD2). Connects via USB serial using mesh companion protocol, captures framebuffer, saves as PNG. Usage: @@ -26,14 +26,19 @@ FRAME_START_IN = 0x3E # '>' - used when receiving FROM device CMD_GET_SCREENSHOT = 66 RESP_CODE_SCREENSHOT = 29 RESP_CODE_ERR = 1 -DISPLAY_WIDTH = 128 -DISPLAY_HEIGHT = 64 -BUFFER_SIZE = (DISPLAY_WIDTH * DISPLAY_HEIGHT) // 8 # 1024 bytes + +# 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) + +# E-ink panel constants for GxEPD2_213_B74 / GxEPD2_213_BN +EINK_PHYS_WIDTH = 128 # full hardware width including invisible columns +EINK_VISIBLE_W = 122 # WIDTH_VISIBLE — columns actually shown on panel def parse_args(): parser = argparse.ArgumentParser( - description="Capture screenshots from Wio L1 Tracker Pro with SH1106 display" + description="Capture screenshots from Wio L1 Tracker Pro (OLED or e-ink)" ) parser.add_argument( "--port", "-p", help="Serial port to use (default: auto-detect)" @@ -101,11 +106,16 @@ def send_command(ser, command, data=b""): def receive_screenshot(ser, timeout=5): - """Receive screenshot data (may be split across multiple frames)""" + """Receive screenshot data (may be split across multiple frames). + + Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, + total_chunks, display_type, [chunk_data...] + """ start_time = time.time() buffer_data = bytearray() width = None height = None + display_type = None total_chunks = None received_chunks = 0 @@ -114,24 +124,26 @@ def receive_screenshot(ser, timeout=5): if frame is None: continue - if len(frame) < 5: + if len(frame) < 6: print(f"Error: Frame too short: {len(frame)} bytes") return None resp_code = frame[0] if resp_code == RESP_CODE_SCREENSHOT: - w = frame[1] - h = frame[2] - chunk_idx = frame[3] - num_chunks = frame[4] - chunk_data = frame[5:] + w = frame[1] + h = frame[2] + chunk_idx = frame[3] + num_chunks = frame[4] + disp_type = frame[5] + chunk_data = frame[6:] if chunk_idx == 0: - width = w - height = h - total_chunks = num_chunks - buffer_data = bytearray() + width = w + height = h + display_type = disp_type + total_chunks = num_chunks + buffer_data = bytearray() received_chunks = 0 if width is None or width != w or height != h: @@ -146,14 +158,7 @@ def receive_screenshot(ser, timeout=5): received_chunks += 1 if received_chunks >= total_chunks: - expected_size = (width * height) // 8 - if len(buffer_data) == expected_size: - return bytes(buffer_data), width, height - else: - print( - f"Error: Expected {expected_size} bytes, got {len(buffer_data)}" - ) - return None + return bytes(buffer_data), width, height, display_type elif resp_code == RESP_CODE_ERR: print("Error: Device returned error") @@ -168,13 +173,13 @@ def receive_screenshot(ser, timeout=5): return None -def buffer_to_image(buffer, width, height, scale=1): - """Convert SH1106/SH1106 framebuffer to PIL Image with white text on black background and black frame""" +def oled_buffer_to_image(buffer, width, height): + """Decode SH1106/SSD1306 framebuffer (page-based, column-major). + Each byte covers 8 vertical pixels; bit 0 = top pixel of the byte. + """ image = Image.new("1", (width, height), 0) pixels = image.load() - bytes_per_page = width - for page in range(0, height, 8): page_start = (page // 8) * bytes_per_page for col in range(width): @@ -183,22 +188,61 @@ def buffer_to_image(buffer, width, height, scale=1): byte_val = buffer[byte_idx] for bit in range(8): if page + bit < height: - pixel_value = 1 if (byte_val & (1 << bit)) else 0 - pixels[col, page + bit] = pixel_value + pixels[col, page + bit] = 1 if (byte_val & (1 << bit)) else 0 + return image.convert("RGB") - # Convert to RGB: white text on black background (no inversion) - image_rgb = image.convert("RGB") - # Add 3-pixel black frame around the image +def eink_buffer_to_image(buffer, log_width, log_height): + """Decode GxEPD2 framebuffer for GxEPD2_213_B74 with DISPLAY_ROTATION=1 (landscape). + + Physical panel: PHYS_WIDTH=128, HEIGHT=250. + Buffer layout: row-major, stride=PHYS_WIDTH/8=16 bytes, MSB-first. + byte index = phys_y * 16 + phys_x // 8 + bit = 7 - phys_x % 8 + value 1 = white (paper), 0 = black (ink) + + With setRotation(1), GxEPD2 maps logical (lx, ly) to physical: + phys_x = PHYS_WIDTH - 1 - ly (= 127 - ly) + phys_y = lx + + Visible logical height = EINK_VISIBLE_W = 122 (PHYS_WIDTH_VISIBLE). + We trim to visible_h rows so the 6 invisible physical columns don't appear. + """ + phys_stride = EINK_PHYS_WIDTH // 8 # 16 bytes per physical row + visible_h = min(log_height, EINK_VISIBLE_W) # trim to 122 + + image = Image.new("RGB", (log_width, visible_h), (255, 255, 255)) + pixels = image.load() + + for ly in range(visible_h): + phys_x = EINK_PHYS_WIDTH - 1 - ly # 127 - ly + for lx in range(log_width): + phys_y = lx + byte_idx = phys_y * phys_stride + phys_x // 8 + bit = 7 - phys_x % 8 + if byte_idx < len(buffer): + raw = (buffer[byte_idx] >> bit) & 1 + # 1=white (paper), 0=black (ink) + color = (255, 255, 255) if raw else (0, 0, 0) + pixels[lx, ly] = color + + return image + + +def buffer_to_image(buffer, width, height, display_type, scale=1): + """Convert framebuffer to PIL Image with a 3-pixel black frame.""" + if display_type == DISPLAY_TYPE_EINK: + image_rgb = eink_buffer_to_image(buffer, width, height) + else: + image_rgb = oled_buffer_to_image(buffer, width, height) + + # Add 3-pixel black frame FRAME_WIDTH = 3 - new_width = width + FRAME_WIDTH * 2 - new_height = height + FRAME_WIDTH * 2 - final_image = Image.new("RGB", (new_width, new_height), (0, 0, 0)) - - # Paste the display content in the center + fw = image_rgb.width + FRAME_WIDTH * 2 + fh = image_rgb.height + FRAME_WIDTH * 2 + final_image = Image.new("RGB", (fw, fh), (0, 0, 0)) final_image.paste(image_rgb, (FRAME_WIDTH, FRAME_WIDTH)) - # Upscale if requested if scale > 1: final_image = final_image.resize( (final_image.width * scale, final_image.height * scale), @@ -240,7 +284,7 @@ def main(): sys.exit(1) try: - print("Screenshot Tool for Wio L1 Tracker Pro") + print("Screenshot Tool for Wio L1 Tracker Pro (OLED + e-ink)") print("Press 'S' to capture screenshot, 'Q' to quit\n") while True: @@ -263,14 +307,15 @@ def main(): result = receive_screenshot(ser) if result: - buffer_data, width, height = result + buffer_data, width, height, display_type = result + type_str = "e-ink" if display_type == DISPLAY_TYPE_EINK else "OLED" print( - f"Received framebuffer: {width}x{height}, {len(buffer_data)} bytes" + f"Received framebuffer: {width}x{height} {type_str}, {len(buffer_data)} bytes" ) try: image = buffer_to_image( - buffer_data, width, height, scale=args.scale + buffer_data, width, height, display_type, scale=args.scale ) filename = generate_filename() image.save(filename) diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 7b67ff89..861960ad 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -9,6 +9,7 @@ build_flags = ${nrf52_base.build_flags} -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 -I variants/wio-tracker-l1 -I examples/companion_radio/ui-new + -I lib/GxEPD2-patch/src -D WIO_TRACKER_L1 -D WIO_TRACKER_L1_EINK -D USE_SX1262 @@ -66,3 +67,10 @@ extends = WioTrackerL1Eink build_flags = ${WioTrackerL1Eink.build_flags} -D DUAL_SERIAL=1 extra_scripts = post:create-uf2.py + +; Dual BLE+USB with screenshot support (development/debug) +[env:WioTrackerL1Eink_companion_dual_dev] +extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D DUAL_SERIAL=1 + -D ENABLE_SCREENSHOT diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 3b09f8ba..8484ec6d 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -152,3 +152,10 @@ extends = WioTrackerL1CompanionDual build_flags = ${WioTrackerL1CompanionDual.build_flags} -D UI_HAS_JOYSTICK_UPDOWN=1 extra_scripts = post:create-uf2.py + +[env:WioTrackerL1_companion_dual_dev] +extends = WioTrackerL1CompanionDual +build_flags = ${WioTrackerL1CompanionDual.build_flags} + -D UI_HAS_JOYSTICK_UPDOWN=1 + -D DUAL_SERIAL=1 + -D ENABLE_SCREENSHOT \ No newline at end of file From 1332358ee71560c4f7bc18e6b0c077b834bead3f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 01:19:22 +0200 Subject: [PATCH 02/11] fix(screenshot): use GxEPD2 visible dimensions; fix phys_x formula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware: add screenshotWidth()/screenshotHeight() virtual pair to DisplayDriver (default: width()/height()). GxEPDDisplay overrides to return display.width()/display.height() — the GxEPD2-reported values that use WIDTH_VISIBLE (e.g. 122 for GxEPD2_213_B74), not the full physical WIDTH stored in DisplayDriver (128). MyMesh uses these instead of display->width()/height() for the screenshot header bytes. Result for GxEPD2_213_B74 + DISPLAY_ROTATION=1: header was 250×128 → now 250×122 (correct visible canvas) Python decoder (tools/screenshot.py): - Remove hardcoded EINK_PHYS_WIDTH=128 / EINK_VISIBLE_W=122 constants. - Derive phys_stride from buffer_size / log_width (works for any panel). - Fix phys_x formula: was (EINK_PHYS_WIDTH-1-ly = 127-ly), now (vis_w-1-ly = log_height-1-ly = 121-ly for 213_B74). Old formula addressed invisible columns 122-127; new formula correctly maps logical rows 0..121 to visible physical columns 121..0. - vis_w = log_height (no hardcoded trim; header now carries correct value). This also works for square panels (e.g. 128×128 where WIDTH=WIDTH_VISIBLE): header will carry 128×128, decoder produces a correct 128×128 image. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 4 +-- src/helpers/ui/DisplayDriver.h | 6 ++++ src/helpers/ui/GxEPDDisplay.h | 4 +++ tools/screenshot.py | 47 +++++++++++++++++------------ 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0a1e5c16..fbb7fb91 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2107,8 +2107,8 @@ void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffe for (int chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) { int i = 0; out_frame[i++] = RESP_CODE_SCREENSHOT; - out_frame[i++] = (uint8_t)display->width(); - out_frame[i++] = (uint8_t)display->height(); + out_frame[i++] = (uint8_t)display->screenshotWidth(); + out_frame[i++] = (uint8_t)display->screenshotHeight(); out_frame[i++] = (uint8_t)chunkIdx; out_frame[i++] = (uint8_t)totalChunks; out_frame[i++] = display->getDisplayType(); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 25a6b871..8829643a 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -254,5 +254,11 @@ public: virtual const uint8_t* getBuffer() { return nullptr; } virtual uint16_t getBufferSize() { return 0; } virtual uint8_t getDisplayType() { return 0; } + // Visible pixel dimensions to embed in the screenshot header. + // Override in e-ink drivers to return the GxEPD2-reported dimensions (which use + // WIDTH_VISIBLE instead of the full physical WIDTH used by DisplayDriver). + // OLED drivers: width()/height() already reflect the visible canvas, so no override needed. + virtual int screenshotWidth() { return width(); } + virtual int screenshotHeight() { return height(); } #endif }; diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 86439463..eb1c7d43 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -82,6 +82,10 @@ public: const uint8_t* getBuffer() override { return display.getBuffer(); } uint16_t getBufferSize() override { return display.getBufferSize(); } uint8_t getDisplayType() override { return 1; } // 1 = e-ink + // Return GxEPD2's own reported dimensions (uses WIDTH_VISIBLE, not the full physical WIDTH + // stored in DisplayDriver). After setRotation(1): width()=HEIGHT, height()=WIDTH_VISIBLE. + int screenshotWidth() override { return (int)display.width(); } + int screenshotHeight() override { return (int)display.height(); } #endif // Line height and approx. char width for each font size: diff --git a/tools/screenshot.py b/tools/screenshot.py index 3de1e811..51da14dc 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -31,9 +31,9 @@ RESP_CODE_ERR = 1 DISPLAY_TYPE_OLED = 0 # page-based, column-major (SH1106/SSD1306) DISPLAY_TYPE_EINK = 1 # row-major, MSB-first, 1=white/0=black (GxEPD2) -# E-ink panel constants for GxEPD2_213_B74 / GxEPD2_213_BN -EINK_PHYS_WIDTH = 128 # full hardware width including invisible columns -EINK_VISIBLE_W = 122 # WIDTH_VISIBLE — columns actually shown on panel +# 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 +# already carries the correct visible height and width for any panel/rotation. def parse_args(): @@ -193,36 +193,43 @@ def oled_buffer_to_image(buffer, width, height): def eink_buffer_to_image(buffer, log_width, log_height): - """Decode GxEPD2 framebuffer for GxEPD2_213_B74 with DISPLAY_ROTATION=1 (landscape). + """Decode GxEPD2 framebuffer for any panel with DISPLAY_ROTATION=1 (landscape). - Physical panel: PHYS_WIDTH=128, HEIGHT=250. - Buffer layout: row-major, stride=PHYS_WIDTH/8=16 bytes, MSB-first. - byte index = phys_y * 16 + phys_x // 8 + The firmware now sends GxEPD2's own width()/height() in the header, which already + reflect WIDTH_VISIBLE (not the full physical WIDTH). So log_width = HEIGHT and + log_height = WIDTH_VISIBLE for this rotation. + + Buffer layout (always in physical panel coordinates, regardless of rotation): + row-major, stride = ceil(phys_width/8) bytes, MSB-first + byte index = phys_x // 8 + phys_y * stride bit = 7 - phys_x % 8 value 1 = white (paper), 0 = black (ink) - With setRotation(1), GxEPD2 maps logical (lx, ly) to physical: - phys_x = PHYS_WIDTH - 1 - ly (= 127 - ly) + With setRotation(1), GxEPD2 maps logical (lx, ly) → physical: + phys_x = WIDTH_VISIBLE - 1 - ly (= log_height - 1 - ly) phys_y = lx - Visible logical height = EINK_VISIBLE_W = 122 (PHYS_WIDTH_VISIBLE). - We trim to visible_h rows so the 6 invisible physical columns don't appear. + phys_stride is derived from buffer size so no panel-specific constants are needed: + phys_stride = buffer_size / phys_height = buffer_size / log_width """ - phys_stride = EINK_PHYS_WIDTH // 8 # 16 bytes per physical row - visible_h = min(log_height, EINK_VISIBLE_W) # trim to 122 + if log_width == 0 or log_height == 0: + return Image.new("RGB", (1, 1), (255, 255, 255)) - image = Image.new("RGB", (log_width, visible_h), (255, 255, 255)) + phys_stride = len(buffer) // log_width # bytes per physical row + vis_w = log_height # WIDTH_VISIBLE — from header after firmware fix + + image = Image.new("RGB", (log_width, vis_w), (255, 255, 255)) pixels = image.load() - for ly in range(visible_h): - phys_x = EINK_PHYS_WIDTH - 1 - ly # 127 - ly + for ly in range(vis_w): + phys_x = vis_w - 1 - ly # WIDTH_VISIBLE - 1 - ly for lx in range(log_width): - phys_y = lx - byte_idx = phys_y * phys_stride + phys_x // 8 - bit = 7 - phys_x % 8 + phys_y = lx + byte_idx = phys_x // 8 + phys_y * phys_stride + bit = 7 - phys_x % 8 if byte_idx < len(buffer): raw = (buffer[byte_idx] >> bit) & 1 - # 1=white (paper), 0=black (ink) + # 1 = white (paper), 0 = black (ink) color = (255, 255, 255) if raw else (0, 0, 0) pixels[lx, ly] = color From 37432491dd4fd033c5071f92aa1609c5492adc0d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 07:54:45 +0200 Subject: [PATCH 03/11] fix(screenshot/eink): handle portrait and landscape rotations in decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous decoder always applied the rotation=1 (landscape) transform, which caused three visible artefacts on a portrait (rotation=0) device: - Content rotated 90° — wrong phys_x/phys_y mapping - Content doubled/skewed — stride = buffer/log_width = 4000/122 = 32 instead of buffer/HEIGHT = 4000/250 = 16 Fix: infer rotation from aspect ratio (portrait: log_height > log_width). portrait → direct mapping: phys_x=lx, phys_y=ly landscape → rotation-1 map: phys_x=log_height-1-ly, phys_y=lx Derive phys_stride from buffer_size // max(log_w, log_h) — the physical HEIGHT is always the longer axis regardless of rotation, giving 16 bytes/row for GxEPD2_213_B74 in both orientations. No panel-specific constants remain; works for any GxEPD2 panel/rotation. Co-Authored-By: Claude Sonnet 4.6 --- tools/screenshot.py | 54 ++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/tools/screenshot.py b/tools/screenshot.py index 51da14dc..7959280c 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -193,43 +193,51 @@ def oled_buffer_to_image(buffer, width, height): def eink_buffer_to_image(buffer, log_width, log_height): - """Decode GxEPD2 framebuffer for any panel with DISPLAY_ROTATION=1 (landscape). + """Decode GxEPD2 framebuffer for any panel/rotation. - The firmware now sends GxEPD2's own width()/height() in the header, which already - reflect WIDTH_VISIBLE (not the full physical WIDTH). So log_width = HEIGHT and - log_height = WIDTH_VISIBLE for this rotation. + The firmware sends GxEPD2's own width()/height() in the header (using WIDTH_VISIBLE, + not the full physical WIDTH). Rotation is inferred from the aspect ratio: + portrait (log_height > log_width) → rotation 0: direct mapping + landscape (log_width > log_height) → rotation 1: phys_x = WIDTH_VISIBLE-1-ly + square (log_width == log_height) → assumed rotation 1 - Buffer layout (always in physical panel coordinates, regardless of rotation): - row-major, stride = ceil(phys_width/8) bytes, MSB-first - byte index = phys_x // 8 + phys_y * stride - bit = 7 - phys_x % 8 - value 1 = white (paper), 0 = black (ink) + Buffer layout (physical panel coordinates, rotation-independent): + row-major, stride bytes per row, MSB-first + byte_idx = phys_x // 8 + phys_y * stride + bit = 7 - phys_x % 8 + 1 = white (paper), 0 = black (ink) - With setRotation(1), GxEPD2 maps logical (lx, ly) → physical: - phys_x = WIDTH_VISIBLE - 1 - ly (= log_height - 1 - ly) - phys_y = lx - - phys_stride is derived from buffer size so no panel-specific constants are needed: - phys_stride = buffer_size / phys_height = buffer_size / log_width + Physical HEIGHT = max(log_width, log_height) for any rotation on a non-square panel. + stride = buffer_size // HEIGHT (no panel-specific constants needed). """ if log_width == 0 or log_height == 0: return Image.new("RGB", (1, 1), (255, 255, 255)) - phys_stride = len(buffer) // log_width # bytes per physical row - vis_w = log_height # WIDTH_VISIBLE — from header after firmware fix + # Physical panel HEIGHT is always the longer dimension. + phys_height = max(log_width, log_height) + phys_stride = len(buffer) // phys_height # bytes per physical row (e.g. 128/8 = 16) - image = Image.new("RGB", (log_width, vis_w), (255, 255, 255)) + image = Image.new("RGB", (log_width, log_height), (255, 255, 255)) pixels = image.load() - for ly in range(vis_w): - phys_x = vis_w - 1 - ly # WIDTH_VISIBLE - 1 - ly + portrait = log_height > log_width # rotation=0 vs rotation=1 + + for ly in range(log_height): for lx in range(log_width): - phys_y = lx + if portrait: + # rotation=0: logical (lx,ly) maps directly to physical (lx,ly) + phys_x = lx + phys_y = ly + else: + # rotation=1: logical (lx,ly) → physical (WIDTH_VISIBLE-1-ly, lx) + # WIDTH_VISIBLE = log_height (from screenshotHeight() in firmware) + phys_x = log_height - 1 - ly + phys_y = lx + byte_idx = phys_x // 8 + phys_y * phys_stride bit = 7 - phys_x % 8 if byte_idx < len(buffer): - raw = (buffer[byte_idx] >> bit) & 1 - # 1 = white (paper), 0 = black (ink) + raw = (buffer[byte_idx] >> bit) & 1 color = (255, 255, 255) if raw else (0, 0, 0) pixels[lx, ly] = color From d1a88c82774c2f20bb8af2d4b4e40a09e5cb6ba7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 08:10:16 +0200 Subject: [PATCH 04/11] fix(screenshot): send GxEPD2 rotation in header; full rot 0-3 decode in Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware (7-byte header): Byte 7 = display->screenshotRotation() — GxEPD2/GFX rotation value (0-3). DisplayDriver::screenshotRotation() defaults to 0 (OLED). GxEPDDisplay::screenshotRotation() returns display.getRotation(), the live GxEPD2 value (reflects runtime setDisplayRotation() calls, not just the compile-time DISPLAY_ROTATION macro). Python decoder: - Parse 7-byte header; pass rotation to eink_buffer_to_image(). - Implement all four GxEPD2 drawPixel coordinate transforms: rot 0: phys_x=lx, phys_y=ly rot 1: phys_x=vis_w-1-ly, phys_y=lx rot 2: phys_x=vis_w-1-lx, phys_y=vis_h-1-ly (180° flip) rot 3: phys_x=ly, phys_y=vis_h-1-lx - vis_w/vis_h derived from log dimensions + rotation parity (no constants). - Print rot= in the status line so the user sees which rotation was used. This fixes the 180° rotated image seen with rotation=2 (portrait inverted) without hardcoding a flip — correct for any rotation value. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 7 ++- src/helpers/ui/DisplayDriver.h | 5 +- src/helpers/ui/GxEPDDisplay.h | 5 +- tools/screenshot.py | 72 ++++++++++++++++++----------- 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fbb7fb91..4c983e27 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2098,9 +2098,11 @@ void MyMesh::handleScreenshotRequest() { } void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize) { - // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, total_chunks, display_type, [data] + // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, total_chunks, + // display_type, rotation, [data] // display_type: 0=OLED (page-based), 1=e-ink (row-major, MSB-first, 1=white/0=black) - const int HEADER_SIZE = 6; + // rotation: 0-3, Adafruit_GFX/GxEPD2 rotation value (only meaningful for e-ink) + const int HEADER_SIZE = 7; const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE; int totalChunks = (bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME; @@ -2112,6 +2114,7 @@ void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffe out_frame[i++] = (uint8_t)chunkIdx; out_frame[i++] = (uint8_t)totalChunks; out_frame[i++] = display->getDisplayType(); + out_frame[i++] = display->screenshotRotation(); 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); diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 8829643a..d83a6edd 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -258,7 +258,8 @@ public: // Override in e-ink drivers to return the GxEPD2-reported dimensions (which use // WIDTH_VISIBLE instead of the full physical WIDTH used by DisplayDriver). // OLED drivers: width()/height() already reflect the visible canvas, so no override needed. - virtual int screenshotWidth() { return width(); } - virtual int screenshotHeight() { return height(); } + virtual int screenshotWidth() { return width(); } + virtual int screenshotHeight() { return height(); } + virtual uint8_t screenshotRotation() { return 0; } // 0-3, GxEPD2/GFX rotation value #endif }; diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index eb1c7d43..90d729be 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -84,8 +84,9 @@ public: uint8_t getDisplayType() override { return 1; } // 1 = e-ink // Return GxEPD2's own reported dimensions (uses WIDTH_VISIBLE, not the full physical WIDTH // stored in DisplayDriver). After setRotation(1): width()=HEIGHT, height()=WIDTH_VISIBLE. - int screenshotWidth() override { return (int)display.width(); } - int screenshotHeight() override { return (int)display.height(); } + int screenshotWidth() override { return (int)display.width(); } + int screenshotHeight() override { return (int)display.height(); } + uint8_t screenshotRotation() override { return (uint8_t)display.getRotation(); } #endif // Line height and approx. char width for each font size: diff --git a/tools/screenshot.py b/tools/screenshot.py index 7959280c..097cedf8 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -124,7 +124,7 @@ def receive_screenshot(ser, timeout=5): if frame is None: continue - if len(frame) < 6: + if len(frame) < 7: print(f"Error: Frame too short: {len(frame)} bytes") return None @@ -136,12 +136,14 @@ def receive_screenshot(ser, timeout=5): chunk_idx = frame[3] num_chunks = frame[4] disp_type = frame[5] - chunk_data = frame[6:] + rotation = frame[6] + chunk_data = frame[7:] if chunk_idx == 0: width = w height = h display_type = disp_type + disp_rotation = rotation total_chunks = num_chunks buffer_data = bytearray() received_chunks = 0 @@ -158,7 +160,7 @@ def receive_screenshot(ser, timeout=5): received_chunks += 1 if received_chunks >= total_chunks: - return bytes(buffer_data), width, height, display_type + return bytes(buffer_data), width, height, display_type, disp_rotation elif resp_code == RESP_CODE_ERR: print("Error: Device returned error") @@ -192,47 +194,60 @@ def oled_buffer_to_image(buffer, width, height): return image.convert("RGB") -def eink_buffer_to_image(buffer, log_width, log_height): - """Decode GxEPD2 framebuffer for any panel/rotation. +def eink_buffer_to_image(buffer, log_width, log_height, rotation): + """Decode GxEPD2 framebuffer for any panel and rotation (0-3). - The firmware sends GxEPD2's own width()/height() in the header (using WIDTH_VISIBLE, - not the full physical WIDTH). Rotation is inferred from the aspect ratio: - portrait (log_height > log_width) → rotation 0: direct mapping - landscape (log_width > log_height) → rotation 1: phys_x = WIDTH_VISIBLE-1-ly - square (log_width == log_height) → assumed rotation 1 + The firmware sends GxEPD2's own width()/height() in the header (uses WIDTH_VISIBLE, + not the full physical WIDTH) and the GxEPD2 rotation value. - Buffer layout (physical panel coordinates, rotation-independent): - row-major, stride bytes per row, MSB-first + Buffer layout (always in physical panel coordinates, rotation-independent): + row-major, stride = GxEPD2_Type::WIDTH / 8 bytes, MSB-first byte_idx = phys_x // 8 + phys_y * stride bit = 7 - phys_x % 8 1 = white (paper), 0 = black (ink) - Physical HEIGHT = max(log_width, log_height) for any rotation on a non-square panel. + Coordinate mapping from GxEPD2 drawPixel (WIDTH = WIDTH_VISIBLE from GFX init): + rot 0: phys_x = lx, phys_y = ly + rot 1: phys_x = WIDTH-1-ly, phys_y = lx (WIDTH = log_height) + rot 2: phys_x = WIDTH-1-lx, phys_y = HEIGHT-1-ly (WIDTH = log_width, HEIGHT = log_height) + rot 3: phys_x = ly, phys_y = HEIGHT-1-lx (HEIGHT = log_width) + + Physical HEIGHT (panel's longer dimension) = max(log_w, log_h) for any rotation. stride = buffer_size // HEIGHT (no panel-specific constants needed). """ if log_width == 0 or log_height == 0: return Image.new("RGB", (1, 1), (255, 255, 255)) - # Physical panel HEIGHT is always the longer dimension. + # Physical HEIGHT is always the longer dimension (panel HEIGHT regardless of rotation). phys_height = max(log_width, log_height) - phys_stride = len(buffer) // phys_height # bytes per physical row (e.g. 128/8 = 16) + phys_stride = len(buffer) // phys_height # bytes per physical row (e.g. 16 for 128-wide panel) + + # vis_w / vis_h are the GFX WIDTH / HEIGHT constants (from GxEPD2's Adafruit_GFX init). + # For odd rotations GFX swaps them, so we reverse-swap to get the physical sizes. + if rotation & 1: # odd: GFX was initialized (WIDTH_VIS, HEIGHT) but swapped + vis_w = log_height # = GFX WIDTH = WIDTH_VISIBLE (e.g. 122) + vis_h = log_width # = GFX HEIGHT = physical HEIGHT (e.g. 250) + else: # even: no swap + vis_w = log_width # = GFX WIDTH = WIDTH_VISIBLE + vis_h = log_height # = GFX HEIGHT = physical HEIGHT image = Image.new("RGB", (log_width, log_height), (255, 255, 255)) pixels = image.load() - portrait = log_height > log_width # rotation=0 vs rotation=1 - for ly in range(log_height): for lx in range(log_width): - if portrait: - # rotation=0: logical (lx,ly) maps directly to physical (lx,ly) + if rotation == 0: phys_x = lx phys_y = ly - else: - # rotation=1: logical (lx,ly) → physical (WIDTH_VISIBLE-1-ly, lx) - # WIDTH_VISIBLE = log_height (from screenshotHeight() in firmware) - phys_x = log_height - 1 - ly + elif rotation == 1: + phys_x = vis_w - 1 - ly # = log_height - 1 - ly phys_y = lx + elif rotation == 2: + phys_x = vis_w - 1 - lx # = log_width - 1 - lx + phys_y = vis_h - 1 - ly # = log_height - 1 - ly + else: # rotation == 3 + phys_x = ly + phys_y = vis_h - 1 - lx # = log_width - 1 - lx byte_idx = phys_x // 8 + phys_y * phys_stride bit = 7 - phys_x % 8 @@ -244,10 +259,10 @@ def eink_buffer_to_image(buffer, log_width, log_height): return image -def buffer_to_image(buffer, width, height, display_type, scale=1): +def buffer_to_image(buffer, width, height, display_type, rotation=0, scale=1): """Convert framebuffer to PIL Image with a 3-pixel black frame.""" if display_type == DISPLAY_TYPE_EINK: - image_rgb = eink_buffer_to_image(buffer, width, height) + image_rgb = eink_buffer_to_image(buffer, width, height, rotation) else: image_rgb = oled_buffer_to_image(buffer, width, height) @@ -322,15 +337,16 @@ def main(): result = receive_screenshot(ser) if result: - buffer_data, width, height, display_type = result + buffer_data, width, height, display_type, disp_rotation = result type_str = "e-ink" if display_type == DISPLAY_TYPE_EINK else "OLED" print( - f"Received framebuffer: {width}x{height} {type_str}, {len(buffer_data)} bytes" + f"Received framebuffer: {width}x{height} {type_str} rot={disp_rotation}, {len(buffer_data)} bytes" ) try: image = buffer_to_image( - buffer_data, width, height, display_type, scale=args.scale + buffer_data, width, height, display_type, + rotation=disp_rotation, scale=args.scale ) filename = generate_filename() image.save(filename) From 9dcbc74636f9ff0b541dcc389d84c850eb2c361d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 08:12:05 +0200 Subject: [PATCH 05/11] fix(screenshot): remove black border from output image Co-Authored-By: Claude Sonnet 4.6 --- tools/screenshot.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tools/screenshot.py b/tools/screenshot.py index 097cedf8..ffa02ef7 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -260,26 +260,19 @@ def eink_buffer_to_image(buffer, log_width, log_height, rotation): def buffer_to_image(buffer, width, height, display_type, rotation=0, scale=1): - """Convert framebuffer to PIL Image with a 3-pixel black frame.""" + """Convert framebuffer to PIL Image, optionally upscaled.""" if 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) - # Add 3-pixel black frame - FRAME_WIDTH = 3 - fw = image_rgb.width + FRAME_WIDTH * 2 - fh = image_rgb.height + FRAME_WIDTH * 2 - final_image = Image.new("RGB", (fw, fh), (0, 0, 0)) - final_image.paste(image_rgb, (FRAME_WIDTH, FRAME_WIDTH)) - if scale > 1: - final_image = final_image.resize( - (final_image.width * scale, final_image.height * scale), + image_rgb = image_rgb.resize( + (image_rgb.width * scale, image_rgb.height * scale), Image.Resampling.NEAREST, ) - return final_image + return image_rgb def generate_filename(): From a84cee0f57b9b34b57cb295ab6fdf907abd02020 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 21:03:05 +0200 Subject: [PATCH 06/11] fix(screenshot): widen header to uint16, sanity-check buffer, fix ERR path Protocol header grows from 7 to 11 bytes: display_type, rotation, then uint16 LE width / height / chunk_idx / total_chunks. Removes silent truncation on panels >255 px (e.g. future 4.2" 400x300 e-ink). Decoder now: - checks RESP_CODE_ERR before length so error frames don't surface as "Frame too short"; logs the error code byte - validates received buffer size against expected (OLED: w*h/8; e-ink: ceil(min(w,h)/8) * max(w,h)) so truncated transfers fail loudly - initializes disp_rotation in outer scope and detects missed chunk 0 Co-Authored-By: Claude Opus 4.7 --- examples/companion_radio/MyMesh.cpp | 29 ++++--- tools/screenshot.py | 126 +++++++++++++++++----------- 2 files changed, 94 insertions(+), 61 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4c983e27..c542fbeb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2098,23 +2098,30 @@ void MyMesh::handleScreenshotRequest() { } void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize) { - // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, total_chunks, - // display_type, rotation, [data] - // display_type: 0=OLED (page-based), 1=e-ink (row-major, MSB-first, 1=white/0=black) - // rotation: 0-3, Adafruit_GFX/GxEPD2 rotation value (only meaningful for e-ink) - const int HEADER_SIZE = 7; + // 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) + // [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) + // [7..8] chunk_idx (uint16 LE) + // [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; - int totalChunks = (bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME; + 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; - for (int chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) { + for (uint16_t chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) { int i = 0; out_frame[i++] = RESP_CODE_SCREENSHOT; - out_frame[i++] = (uint8_t)display->screenshotWidth(); - out_frame[i++] = (uint8_t)display->screenshotHeight(); - out_frame[i++] = (uint8_t)chunkIdx; - out_frame[i++] = (uint8_t)totalChunks; out_frame[i++] = display->getDisplayType(); out_frame[i++] = display->screenshotRotation(); + 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); diff --git a/tools/screenshot.py b/tools/screenshot.py index ffa02ef7..6579cbf7 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -105,70 +105,96 @@ def send_command(ser, command, data=b""): return frame_len + 3 -def receive_screenshot(ser, timeout=5): - """Receive screenshot data (may be split across multiple frames). +HEADER_SIZE = 11 # see firmware sendScreenshotResponse() for layout - Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_idx, - total_chunks, display_type, [chunk_data...] - """ - start_time = time.time() - buffer_data = bytearray() - width = None - height = None - display_type = None - total_chunks = None + +def _u16le(buf, off): + return buf[off] | (buf[off + 1] << 8) + + +def _expected_buffer_size(width, height, display_type): + """Bytes the firmware should send for given dims+type.""" + if display_type == DISPLAY_TYPE_EINK: + # GxEPD2 buffer is in physical panel coordinates: row-major, + # stride = ceil(WIDTH_VISIBLE / 8) bytes, HEIGHT rows. + # WIDTH_VISIBLE = shorter visible dim regardless of rotation. + visible_short = min(width, height) + phys_stride = (visible_short + 7) // 8 + phys_height = max(width, height) + return phys_stride * phys_height + # OLED: packed bits, no padding + return (width * height) // 8 + + +def receive_screenshot(ser, timeout=5): + """Receive a multi-chunk screenshot response. Returns (buf, w, h, type, rot) or None.""" + start_time = time.time() + buffer_data = bytearray() + width = None + height = None + display_type = None + disp_rotation = 0 + total_chunks = None received_chunks = 0 while time.time() - start_time < timeout: frame = read_frame(ser) if frame is None: continue - - if len(frame) < 7: - print(f"Error: Frame too short: {len(frame)} bytes") - return None + if len(frame) < 1: + continue resp_code = frame[0] - if resp_code == RESP_CODE_SCREENSHOT: - w = frame[1] - h = frame[2] - chunk_idx = frame[3] - num_chunks = frame[4] - disp_type = frame[5] - rotation = frame[6] - chunk_data = frame[7:] - - if chunk_idx == 0: - width = w - height = h - display_type = disp_type - disp_rotation = rotation - total_chunks = num_chunks - buffer_data = bytearray() - received_chunks = 0 - - if width is None or width != w or height != h: - print(f"Error: Inconsistent dimensions in chunk {chunk_idx}") - return None - - if total_chunks is None or total_chunks != num_chunks: - print(f"Error: Inconsistent chunk count in chunk {chunk_idx}") - return None - - buffer_data.extend(chunk_data) - received_chunks += 1 - - if received_chunks >= total_chunks: - return bytes(buffer_data), width, height, display_type, disp_rotation - - elif resp_code == RESP_CODE_ERR: - print("Error: Device returned error") + if resp_code == RESP_CODE_ERR: + err = frame[1] if len(frame) > 1 else 0xFF + print(f"Error: Device returned error code 0x{err:02x}") return None - else: + if resp_code != RESP_CODE_SCREENSHOT: continue + if len(frame) < HEADER_SIZE: + print(f"Error: Screenshot frame too short: {len(frame)} bytes (need >= {HEADER_SIZE})") + return None + + disp_type = frame[1] + rotation = frame[2] + w = _u16le(frame, 3) + h = _u16le(frame, 5) + chunk_idx = _u16le(frame, 7) + num_chunks = _u16le(frame, 9) + chunk_data = frame[HEADER_SIZE:] + + if chunk_idx == 0: + width = w + height = h + display_type = disp_type + disp_rotation = rotation + total_chunks = num_chunks + buffer_data = bytearray() + received_chunks = 0 + + if width is None: + print(f"Error: Missed chunk 0 (first chunk seen: {chunk_idx})") + return None + if width != w or height != h: + print(f"Error: Inconsistent dimensions in chunk {chunk_idx}") + return None + if total_chunks != num_chunks: + print(f"Error: Inconsistent chunk count in chunk {chunk_idx}") + return None + + buffer_data.extend(chunk_data) + received_chunks += 1 + + if received_chunks >= total_chunks: + expected = _expected_buffer_size(width, height, display_type) + if len(buffer_data) != expected: + print(f"Error: Expected {expected} bytes, got {len(buffer_data)}") + return None + return bytes(buffer_data), width, height, display_type, disp_rotation + print( f"Error: Timeout waiting for screenshot (received {received_chunks}/{total_chunks or '?'} chunks)" ) From 6687d2b0e63793176e5d8bb728c8159070adae39 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 22:57:50 +0200 Subject: [PATCH 07/11] fix(ui/dashboard): Batt% uses same LiPo curve + low_batt_mv cutoff as top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Clock-page Batt% field used a separate hardcoded linear mapping (3000-4200 mV → 0-100%) that disagreed with the top-bar battery indicator, which uses a piecewise LiPo discharge curve and the user-configurable low_batt_mv cutoff (Settings → Low battery threshold) as the 0% anchor. The two readings could differ by 25+ percentage points for the same voltage, and the dashboard ignored the cutoff setting entirely. Extract battMvToPercent(mv, low_mv) as a file-static helper so both the top-bar indicator and the dashboard field share the same curve and the same cutoff source. Drop the stray BATT_MIN/MAX_MILLIVOLTS #defines that leaked from inside the dashboard code paths into the rest of the TU. Co-Authored-By: Claude Opus 4.7 --- examples/companion_radio/ui-new/UITask.cpp | 112 +++++++++------------ 1 file changed, 47 insertions(+), 65 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5b04f9a7..b5c5a2ff 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -119,6 +119,43 @@ static const int QUICK_MSGS_MAX = 10; #include "TrailScreen.h" #include "ToolsScreen.h" +#ifndef BATT_MIN_MILLIVOLTS + #define BATT_MIN_MILLIVOLTS 3200 +#endif + +// LiPo discharge curve: voltage (mV) → raw capacity (%). Shared by the top-bar +// battery indicator and the dashboard Batt% field so both report the same +// number for the same voltage. low_mv (typically NodePrefs.low_batt_mv, the +// user-configurable auto-shutdown threshold in Settings) is rescaled to 0% +// so the bar empties at the cutoff the user actually cares about. +static int battMvToPercent(int mv, int low_mv) { + static const struct { uint16_t mv; uint8_t pct; } CURVE[] = { + {3200, 0}, {3300, 3}, {3400, 8}, {3500, 15}, + {3600, 25}, {3650, 33}, {3700, 45}, {3750, 58}, + {3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4200, 100} + }; + static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]); + auto curveAt = [&](int v) -> int { + if (v <= (int)CURVE[0].mv) return CURVE[0].pct; + if (v >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct; + for (int i = 1; i < CURVE_LEN; i++) { + if (v <= (int)CURVE[i].mv) { + int span_mv = CURVE[i].mv - CURVE[i-1].mv; + int span_pct = CURVE[i].pct - CURVE[i-1].pct; + return CURVE[i-1].pct + (v - (int)CURVE[i-1].mv) * span_pct / span_mv; + } + } + return 100; + }; + if (low_mv <= 0) low_mv = BATT_MIN_MILLIVOLTS; + int raw_pct = curveAt(mv); + int low_pct = curveAt(low_mv); + int pct = (low_pct >= 100) ? 0 : (raw_pct - low_pct) * 100 / (100 - low_pct); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + return pct; +} + // ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen : public UIScreen { enum HomePage { @@ -288,39 +325,8 @@ class HomeScreen : public UIScreen { } int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { -#ifndef BATT_MIN_MILLIVOLTS - #define BATT_MIN_MILLIVOLTS 3200 -#endif - // LiPo discharge curve: voltage (mV) → raw capacity (%) - static const struct { uint16_t mv; uint8_t pct; } CURVE[] = { - {3200, 0}, {3300, 3}, {3400, 8}, {3500, 15}, - {3600, 25}, {3650, 33}, {3700, 45}, {3750, 58}, - {3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4200, 100} - }; - static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]); - - auto curveAt = [&](int mv) -> int { - if (mv <= (int)CURVE[0].mv) return CURVE[0].pct; - if (mv >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct; - for (int i = 1; i < CURVE_LEN; i++) { - if (mv <= (int)CURVE[i].mv) { - int span_mv = CURVE[i].mv - CURVE[i-1].mv; - int span_pct = CURVE[i].pct - CURVE[i-1].pct; - return CURVE[i-1].pct + (mv - (int)CURVE[i-1].mv) * span_pct / span_mv; - } - } - return 100; - }; - - int low_mv = (_node_prefs && _node_prefs->low_batt_mv > 0) - ? (int)_node_prefs->low_batt_mv : BATT_MIN_MILLIVOLTS; - int raw_pct = curveAt((int)batteryMilliVolts); - int low_pct = curveAt(low_mv); - // rescale so low_mv = 0% and 4200mV = 100% - int pct = (low_pct >= 100) ? 0 - : (raw_pct - low_pct) * 100 / (100 - low_pct); - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; + int low_mv = _node_prefs ? (int)_node_prefs->low_batt_mv : 0; + int pct = battMvToPercent((int)batteryMilliVolts, low_mv); uint8_t mode = (_node_prefs && _node_prefs->batt_display_mode < 3) ? _node_prefs->batt_display_mode : 0; @@ -557,21 +563,9 @@ public: } else if (field == DASH_BATT_PCT) { strcpy(label, "Batt"); uint16_t mv = _task->getBattMilliVolts(); - if (mv > 0) { - // Simple linear voltage to percentage calculation - #ifndef BATT_MIN_MILLIVOLTS - #define BATT_MIN_MILLIVOLTS 3000 - #endif - #ifndef BATT_MAX_MILLIVOLTS - #define BATT_MAX_MILLIVOLTS 4200 - #endif - int percent = ((mv - BATT_MIN_MILLIVOLTS) * 100) / (BATT_MAX_MILLIVOLTS - BATT_MIN_MILLIVOLTS); - if (percent < 0) percent = 0; - if (percent > 100) percent = 100; - snprintf(val, sizeof(val), "%d%%", percent); - } else { - strcpy(val, "--"); - } + if (mv > 0) snprintf(val, sizeof(val), "%d%%", + battMvToPercent(mv, _node_prefs->low_batt_mv)); + else strcpy(val, "--"); } else if (field == DASH_GPS) { strcpy(label, "GPS"); #if ENV_INCLUDE_GPS == 1 @@ -1438,7 +1432,7 @@ bool UITask::isButtonPressed() const { } static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, - CayenneLPP* lpp = nullptr) { + uint16_t low_batt_mv, CayenneLPP* lpp = nullptr) { val[0] = '\0'; switch (field) { case DASH_NONE: return; @@ -1447,20 +1441,8 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m else strcpy(val, "--"); return; case DASH_BATT_PCT: - if (batt_mv > 0) { - #ifndef BATT_MIN_MILLIVOLTS - #define BATT_MIN_MILLIVOLTS 3000 - #endif - #ifndef BATT_MAX_MILLIVOLTS - #define BATT_MAX_MILLIVOLTS 4200 - #endif - int percent = ((batt_mv - BATT_MIN_MILLIVOLTS) * 100) / (BATT_MAX_MILLIVOLTS - BATT_MIN_MILLIVOLTS); - if (percent < 0) percent = 0; - if (percent > 100) percent = 100; - snprintf(val, val_len, "%d%%", percent); - } else { - strcpy(val, "--"); - } + if (batt_mv > 0) snprintf(val, val_len, "%d%%", battMvToPercent(batt_mv, low_batt_mv)); + else strcpy(val, "--"); return; case DASH_NODES: snprintf(val, val_len, "%d nodes", the_mesh.getNumContacts()); @@ -1704,8 +1686,8 @@ void UITask::loop() { if (isLPP(f0) || isLPP(f1)) { _dash_lpp.reset(); sensors.querySensors(0xFF, _dash_lpp); lpp_ptr = &_dash_lpp; } - formatDashVal(f0, v0, sizeof(v0), _batt_mv, lpp_ptr); - formatDashVal(f1, v1, sizeof(v1), _batt_mv, lpp_ptr); + formatDashVal(f0, v0, sizeof(v0), _batt_mv, _node_prefs->low_batt_mv, lpp_ptr); + formatDashVal(f1, v1, sizeof(v1), _batt_mv, _node_prefs->low_batt_mv, lpp_ptr); if (v0[0] || v1[0]) { int sv_y = date_y + lk_step; _display->setColor(DisplayDriver::LIGHT); From 5dd035d3098ef37161aadf9edca6979fb21dc5ff Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 22:58:26 +0200 Subject: [PATCH 08/11] fix(screenshot): drop C-style cast UB, fix ERR check order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleScreenshotRequest() reinterpret-cast every DisplayDriver* first to SH1106Display* then to SSD1306Display*; both casts always produce a non-null pointer, so the SSD1306 fallback was unreachable and on any SSD1306 build we called SH1106::getBuffer() on a SSD1306 object — UB that only happened to work because both backing classes share an Adafruit_GFX layout at offset zero. Replace the cast hack with virtual getBuffer()/getBufferSize() on DisplayDriver, overridden in SH1106Display and SSD1306Display. MyMesh no longer needs to know about either concrete type. const-correct the buffer pointer and widen bufferSize to uint16_t while passing through. Also fix the matching Python decoder: it sanity-checked length before dispatching on resp_code, so a 2-byte RESP_CODE_ERR frame was reported as "Frame too short" instead of the actual device error. Move the length check after the resp_code dispatch and log the error code byte. Co-Authored-By: Claude Opus 4.7 --- examples/companion_radio/MyMesh.cpp | 56 +++++++------------ examples/companion_radio/MyMesh.h | 2 +- src/helpers/ui/DisplayDriver.h | 8 +++ src/helpers/ui/SH1106Display.h | 4 +- src/helpers/ui/SSD1306Display.h | 4 +- tools/screenshot.py | 85 +++++++++++++++-------------- 6 files changed, 76 insertions(+), 83 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 495ecc3b..bf1b6b60 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -7,8 +7,6 @@ #ifdef DISPLAY_CLASS #include "helpers/ui/DisplayDriver.h" -#include "helpers/ui/SH1106Display.h" -#include "helpers/ui/SSD1306Display.h" #include "UITask.h" #endif @@ -2083,29 +2081,13 @@ void MyMesh::handleScreenshotRequest() { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); return; } - - uint8_t* buffer = nullptr; - int bufferSize = 0; - - // Try SH1106Display first (used by Wio L1) - // Use C-style cast since RTTI is disabled (-fno-rtti) - SH1106Display* sh1106 = (SH1106Display*)display; - if (sh1106) { - buffer = sh1106->getBuffer(); - bufferSize = (display->width() * display->height()) / 8; - } else { - // Fallback: try SSD1306Display - SSD1306Display* ssd1306 = (SSD1306Display*)display; - if (ssd1306) { - buffer = ssd1306->getBuffer(); - bufferSize = (display->width() * display->height()) / 8; - } - } - + + const uint8_t* buffer = display->getBuffer(); + uint16_t bufferSize = display->getBufferSize(); + if (buffer && bufferSize > 0) { sendScreenshotResponse(display, buffer, bufferSize); } else { - // No supported display type writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } #else @@ -2113,26 +2095,28 @@ void MyMesh::handleScreenshotRequest() { #endif } -void MyMesh::sendScreenshotResponse(DisplayDriver* display, uint8_t* buffer, int bufferSize) { - // MAX_FRAME_SIZE is 172 bytes, but we need to send 1026 bytes (3 header + 1024 buffer) - // We need to split into multiple frames - // Frame format: RESP_CODE_SCREENSHOT, width, height, chunk_index, total_chunks, [chunk_data...] - - const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - 5; // 5 bytes header +void MyMesh::sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize) { + // Frame format (5-byte header): RESP_CODE_SCREENSHOT, width, height, + // chunk_idx, total_chunks, [chunk_data...] + // width/height fit in uint8_t for all displays this firmware targets + // (SH1106 128×64, SSD1306 128×64); the framebuffer is page-based 1bpp + // column-major, byte_idx = page*width + col. + const int HEADER_SIZE = 5; + const int MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE; int totalChunks = (bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME; - + for (int chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) { int i = 0; out_frame[i++] = RESP_CODE_SCREENSHOT; - out_frame[i++] = display->width(); - out_frame[i++] = display->height(); - out_frame[i++] = chunkIdx; - out_frame[i++] = totalChunks; - - int chunkSize = min(MAX_DATA_PER_FRAME, bufferSize - chunkIdx * MAX_DATA_PER_FRAME); + out_frame[i++] = (uint8_t)display->width(); + out_frame[i++] = (uint8_t)display->height(); + out_frame[i++] = (uint8_t)chunkIdx; + out_frame[i++] = (uint8_t)totalChunks; + + 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); i += chunkSize; - + _serial->writeFrame(out_frame, i); } } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index c7aa7366..f6a2eef3 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -229,7 +229,7 @@ private: bool isValidClientRepeatFreq(uint32_t f) const; #ifdef ENABLE_SCREENSHOT void handleScreenshotRequest(); - void sendScreenshotResponse(DisplayDriver* display, uint8_t* buffer, int bufferSize); + void sendScreenshotResponse(DisplayDriver* display, const uint8_t* buffer, uint16_t bufferSize); #endif UITask* getUITask() { return (UITask*)_ui; } diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 3eeb1b6b..cb173bb2 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -247,4 +247,12 @@ public: virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays virtual void setFullRefreshInterval(uint8_t n) { } // e-ink: do full refresh every n partial refreshes (0=never) virtual void endFrame() = 0; + +#ifdef ENABLE_SCREENSHOT + // Screenshot support — virtual so we never reinterpret_cast the concrete + // display type (RTTI is disabled). Drivers that can dump their framebuffer + // override these; default returns nullptr/0 so the caller fails cleanly. + virtual const uint8_t* getBuffer() { return nullptr; } + virtual uint16_t getBufferSize() { return 0; } +#endif }; diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 134db133..87c26b17 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -60,7 +60,7 @@ public: void endFrame() override; #ifdef ENABLE_SCREENSHOT - // Screenshot support - uint8_t* getBuffer() { return display.getBuffer(); } + const uint8_t* getBuffer() override { return display.getBuffer(); } + uint16_t getBufferSize() override { return (uint16_t)((width() * height()) / 8); } #endif }; diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index a5ce696e..c69a493c 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -47,7 +47,7 @@ public: void endFrame() override; #ifdef ENABLE_SCREENSHOT - // Screenshot support - uint8_t* getBuffer() { return display.getBuffer(); } + const uint8_t* getBuffer() override { return display.getBuffer(); } + uint16_t getBufferSize() override { return (uint16_t)((width() * height()) / 8); } #endif }; diff --git a/tools/screenshot.py b/tools/screenshot.py index 750488dd..39b7fdd3 100755 --- a/tools/screenshot.py +++ b/tools/screenshot.py @@ -113,55 +113,56 @@ def receive_screenshot(ser, timeout=5): frame = read_frame(ser) if frame is None: continue - - if len(frame) < 5: - print(f"Error: Frame too short: {len(frame)} bytes") - return None + if len(frame) < 1: + continue resp_code = frame[0] - if resp_code == RESP_CODE_SCREENSHOT: - w = frame[1] - h = frame[2] - chunk_idx = frame[3] - num_chunks = frame[4] - chunk_data = frame[5:] - - if chunk_idx == 0: - width = w - height = h - total_chunks = num_chunks - buffer_data = bytearray() - received_chunks = 0 - - if width is None or width != w or height != h: - print(f"Error: Inconsistent dimensions in chunk {chunk_idx}") - return None - - if total_chunks is None or total_chunks != num_chunks: - print(f"Error: Inconsistent chunk count in chunk {chunk_idx}") - return None - - buffer_data.extend(chunk_data) - received_chunks += 1 - - if received_chunks >= total_chunks: - expected_size = (width * height) // 8 - if len(buffer_data) == expected_size: - return bytes(buffer_data), width, height - else: - print( - f"Error: Expected {expected_size} bytes, got {len(buffer_data)}" - ) - return None - - elif resp_code == RESP_CODE_ERR: - print("Error: Device returned error") + if resp_code == RESP_CODE_ERR: + err = frame[1] if len(frame) > 1 else 0xFF + print(f"Error: Device returned error code 0x{err:02x}") return None - else: + if resp_code != RESP_CODE_SCREENSHOT: continue + if len(frame) < 5: + print(f"Error: Screenshot frame too short: {len(frame)} bytes (need >= 5)") + return None + + w = frame[1] + h = frame[2] + chunk_idx = frame[3] + num_chunks = frame[4] + chunk_data = frame[5:] + + if chunk_idx == 0: + width = w + height = h + total_chunks = num_chunks + buffer_data = bytearray() + received_chunks = 0 + + if width is None: + print(f"Error: Missed chunk 0 (first chunk seen: {chunk_idx})") + return None + if width != w or height != h: + print(f"Error: Inconsistent dimensions in chunk {chunk_idx}") + return None + if total_chunks != num_chunks: + print(f"Error: Inconsistent chunk count in chunk {chunk_idx}") + return None + + buffer_data.extend(chunk_data) + received_chunks += 1 + + if received_chunks >= total_chunks: + expected_size = (width * height) // 8 + if len(buffer_data) != expected_size: + print(f"Error: Expected {expected_size} bytes, got {len(buffer_data)}") + return None + return bytes(buffer_data), width, height + print( f"Error: Timeout waiting for screenshot (received {received_chunks}/{total_chunks or '?'} chunks)" ) From 95795a663cce8129b4a32af07add364f6dc2aa87 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 23:20:15 +0200 Subject: [PATCH 09/11] feat(ui): popup menu auto-expands on tall displays (portrait e-ink) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a 250px-tall portrait e-ink panel the menu previously showed only 3-4 items and required scrolling. render() now computes the maximum items that fit from display.height() and uses that instead of the hardcoded caller hint whenever the screen is taller. handleInput() uses the same _cap value so scroll tracking stays in sync. OLED (64px): fits 4 items — unchanged behaviour. Portrait e-ink (250px): fits up to 22 items (capped at PM_MAX_ITEMS=16), no scroll needed for typical context menus (4-6 items). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/PopupMenu.h | 22 +++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index 94748870..05fa6ac8 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -16,17 +16,18 @@ struct PopupMenu { int _count; int _sel; int _scroll; - int _visible; + int _visible; // caller hint (min visible items) + int _cap; // actual visible cap, updated each render() bool active; const char* _title; enum Result { NONE, SELECTED, CANCELLED }; - PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), active(false), _title(nullptr) {} + PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), _cap(3), active(false), _title(nullptr) {} void begin(const char* title, int visible = 3) { _count = 0; _sel = 0; _scroll = 0; - _visible = visible; active = true; _title = title; + _visible = visible; _cap = visible; active = true; _title = title; } void addItem(const char* item) { @@ -34,7 +35,12 @@ struct PopupMenu { } int render(DisplayDriver& display) { - int vis = (_count < _visible) ? _count : _visible; + // On tall displays (portrait e-ink) show as many items as fit; + // on small displays fall back to the caller-specified _visible cap. + int max_by_height = (display.height() - PM_BY - 12) / PM_ITEM_H; + if (max_by_height < 1) max_by_height = 1; + _cap = (max_by_height > _visible) ? max_by_height : _visible; + int vis = (_count < _cap) ? _count : _cap; int bh = 12 + vis * PM_ITEM_H; display.setColor(DisplayDriver::DARK); @@ -71,14 +77,14 @@ struct PopupMenu { if (_count == 0) { active = false; return CANCELLED; } if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; - if (_sel < _scroll) _scroll = _sel; - else if (_sel == _count - 1 && _count > _visible) _scroll = _count - _visible; + if (_sel < _scroll) _scroll = _sel; + else if (_sel == _count - 1 && _count > _cap) _scroll = _count - _cap; return NONE; } if (c == KEY_DOWN) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; - if (_sel >= _scroll + _visible) _scroll = _sel - _visible + 1; - else if (_sel == 0) _scroll = 0; + if (_sel >= _scroll + _cap) _scroll = _sel - _cap + 1; + else if (_sel == 0) _scroll = 0; return NONE; } if (c == KEY_ENTER) { active = false; return SELECTED; } From 5caac0b693b6c33446c155417f71280e0e91fc0b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 28 May 2026 23:43:45 +0200 Subject: [PATCH 10/11] =?UTF-8?q?fix(ui/trail):=20clarify=20export=20menu?= =?UTF-8?q?=20labels=20=E2=80=94=20live=20vs=20saved=20GPX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Export GPX' and 'Export saved' were ambiguous. Renamed to 'Export GPX (live)' (RAM ring) and 'Export GPX (saved)' (flash file) so the source of each export is immediately clear. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/TrailScreen.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 9c725c9c..3d7f6df2 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -229,10 +229,10 @@ private: _act_map[_act_count++] = ACT_LOAD; _action_menu.addItem("Load trail"); } if (!_store->empty()) { - _act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX"); + _act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX (live)"); } if (saved) { - _act_map[_act_count++] = ACT_EXPORT_SAVED; _action_menu.addItem("Export saved"); + _act_map[_act_count++] = ACT_EXPORT_SAVED; _action_menu.addItem("Export GPX (saved)"); } if (!_store->empty()) { _act_map[_act_count++] = ACT_RESET; _action_menu.addItem("Reset trail"); From db8c52ea9a57c47ca04032e54a816041d290bbcd Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 29 May 2026 00:25:02 +0200 Subject: [PATCH 11/11] fix(ui): standardize context menu cycling + PopupMenu height clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PopupMenu: - _cap now uses max_by_height as a hard ceiling (never draw outside screen). Previously max(_visible, max_by_height) let callers exceed the screen on OLED 64px. Now: OLED→4 items, landscape e-ink→10, portrait e-ink→22. QuickMsgScreen: - Contact context menu: Notif and Melody cycle with LEFT/RIGHT in-place; ENTER closes without re-cycling. - Channel context menu: Notif, Melody and Fav same pattern. RingtoneEditorScreen: - Migrated from bespoke menu to PopupMenu (same pattern as everywhere else). - Duration (1/4…1/32) and BPM (60…180) are now single rows that cycle with LEFT/RIGHT; separate BPM+/BPM- rows removed. - Fixed _menu_dur_label buffer too small for "Duration: 1/16" (14→16 bytes). NearbyScreen: - Removed redundant "Back" item from context menu (Cancel key navigates back). TrailScreen: - Grid toggle responds to LEFT/RIGHT in addition to ENTER. - Export labels: "Export GPX (live/saved)" → "Export (live/saved)" to fit PM_BW. Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 11 +- examples/companion_radio/ui-new/PopupMenu.h | 7 +- .../companion_radio/ui-new/QuickMsgScreen.h | 99 +++++--- .../ui-new/RingtoneEditorScreen.h | 221 ++++++++---------- examples/companion_radio/ui-new/TrailScreen.h | 5 +- 5 files changed, 178 insertions(+), 165 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 6bb339d5..bdb14e6f 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -516,21 +516,16 @@ public: // ── context menu ───────────────────────────────────────────────────────── if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED) { - if (_ctx_menu.selectedIndex() == 0) - enterDiscoverMode(); - else - _task->gotoToolsScreen(); - } + if (res == PopupMenu::SELECTED) + enterDiscoverMode(); return true; } // ── list view ──────────────────────────────────────────────────────────── if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } if (c == KEY_CONTEXT_MENU) { - _ctx_menu.begin("Options", 2); + _ctx_menu.begin("Options", 1); _ctx_menu.addItem("Discover nearby"); - _ctx_menu.addItem("Back"); return true; } if (c == KEY_UP && _sel > 0) { diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index 05fa6ac8..59cdfd57 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -35,11 +35,12 @@ struct PopupMenu { } int render(DisplayDriver& display) { - // On tall displays (portrait e-ink) show as many items as fit; - // on small displays fall back to the caller-specified _visible cap. + // Hard ceiling: never show more items than physically fit on screen. + // On tall displays (portrait e-ink) this expands beyond _visible; + // on small displays (OLED 64px) it clamps below _visible. int max_by_height = (display.height() - PM_BY - 12) / PM_ITEM_H; if (max_by_height < 1) max_by_height = 1; - _cap = (max_by_height > _visible) ? max_by_height : _visible; + _cap = max_by_height; int vis = (_count < _cap) ? _count : _cap; int bh = 12 + vis * PM_ITEM_H; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index f608e7d8..3feed0a7 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -1021,6 +1021,33 @@ public: } else if (_phase == CONTACT_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { + // LEFT/RIGHT cycle Notif/Melody in-place (menu stays open). + if (!_pin_picker_active && _num_contacts > 0) { + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + if (left || right) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + static const char* ML[] = { "global", "M1", "M2" }; + ContactInfo ci; + if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { + int sel = _ctx_menu.selectedIndex(); + if (sel == 1) { + uint8_t v = dmNotifState(ci.id.pub_key); + v = right ? (v + 1) % 3 : (v + 2) % 3; + setDmNotifState(ci.id.pub_key, v); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]); + _ctx_dirty = true; + } else if (sel == 2) { + uint8_t v = dmMelodySlot(ci.id.pub_key); + v = right ? (v + 1) % 3 : (v + 2) % 3; + setDmMelody(ci.id.pub_key, v); + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]); + _ctx_dirty = true; + } + } + return true; + } + } auto res = _ctx_menu.handleInput(c); if (_pin_picker_active) { // Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot. @@ -1041,17 +1068,10 @@ public: if (res == PopupMenu::SELECTED && _num_contacts > 0) { ContactInfo ci; if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { - if (_ctx_menu.selectedIndex() == 0) { + int sel = _ctx_menu.selectedIndex(); + if (sel == 0) { _task->clearDMUnread(ci.id.pub_key); - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = dmNotifState(ci.id.pub_key); - setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); - _ctx_dirty = true; - } else if (_ctx_menu.selectedIndex() == 2) { - uint8_t slot = dmMelodySlot(ci.id.pub_key); - setDmMelody(ci.id.pub_key, (slot + 1) % 3); - _ctx_dirty = true; - } else if (_ctx_menu.selectedIndex() == 3) { + } else if (sel == 3) { // Pin / Unpin int pinned_slot = _task->findFavouriteSlot(ci.id.pub_key); if (pinned_slot >= 0) { @@ -1061,12 +1081,10 @@ public: snprintf(alert, sizeof(alert), "Unpinned (slot %d)", pinned_slot + 1); _task->showAlert(alert, 800); } else { - // Open slot picker. Each label shows "Slot N: " or "Slot N: empty". for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) { if (_task->isFavouriteSlotEmpty(s)) { snprintf(_pin_slot_labels[s], sizeof(_pin_slot_labels[s]), "Slot %d: empty", s + 1); } else { - // Resolve current occupant name by walking contacts. char nm[16] = "?"; NodePrefs* p = _task->getNodePrefs(); if (p) { @@ -1088,6 +1106,7 @@ public: _pin_picker_active = true; } } + // sel == 1 (Notif) and sel == 2 (Melody): already cycled via LEFT/RIGHT; ENTER just closes. } } if (res != PopupMenu::NONE && _ctx_dirty) { @@ -1140,29 +1159,49 @@ public: } else if (_phase == CHANNEL_PICK) { // Context menu consumes all input while open if (_ctx_menu.active) { + // LEFT/RIGHT cycle Notif/Melody/Fav in-place (menu stays open). + if (_num_channels > 0) { + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + if (left || right) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + static const char* ML[] = { "global", "M1", "M2" }; + uint8_t ch_idx = _channel_indices[_channel_sel]; + int sel = _ctx_menu.selectedIndex(); + if (sel == 1) { + uint8_t v = chNotifState(ch_idx); + v = right ? (v + 1) % 3 : (v + 2) % 3; + setChNotifState(ch_idx, v); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[v]); + _ctx_dirty = true; + } else if (sel == 2) { + uint8_t v = chNotifMelody(ch_idx); + v = right ? (v + 1) % 3 : (v + 2) % 3; + setChNotifMelody(ch_idx, v); + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", ML[v]); + _ctx_dirty = true; + } else if (sel == 3) { + NodePrefs* p2 = _task->getNodePrefs(); + if (p2) { + p2->ch_fav_bitmask ^= (1ULL << ch_idx); + bool is_fav = (p2->ch_fav_bitmask & (1ULL << ch_idx)); + snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no"); + _ctx_dirty = true; + buildChannelList(); + if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0; + } + } + return true; + } + } auto res = _ctx_menu.handleInput(c); if (res == PopupMenu::SELECTED && _num_channels > 0) { uint8_t ch_idx = _channel_indices[_channel_sel]; - if (_ctx_menu.selectedIndex() == 0) { + int sel = _ctx_menu.selectedIndex(); + if (sel == 0) { _ch_unread[ch_idx] = 0; - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = chNotifState(ch_idx); - setChNotifState(ch_idx, (nstate + 1) % 3); - _ctx_dirty = true; - } else if (_ctx_menu.selectedIndex() == 2) { - uint8_t slot = chNotifMelody(ch_idx); - setChNotifMelody(ch_idx, (slot + 1) % 3); - _ctx_dirty = true; - } else { - // Toggle favourite - NodePrefs* p2 = _task->getNodePrefs(); - if (p2) { - p2->ch_fav_bitmask ^= (1ULL << ch_idx); - _ctx_dirty = true; - buildChannelList(); // refilter if ch_fav_only is active - if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0; - } } + // sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes. } if (res != PopupMenu::NONE && _ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 9767d30c..e6b2bdab 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -6,8 +6,10 @@ class RingtoneEditorScreen : public UIScreen { UITask* _task; NodePrefs* _prefs; - static const int MAX_NOTES = 32; - static const int MENU_VISIBLE = 5; + static const int MAX_NOTES = 32; + + // Menu item indices + enum MenuIdx { MI_PLAY=0, MI_SWITCH, MI_DURATION, MI_BPM, MI_INSERT, MI_DELETE, MI_SAVE, MI_DISCARD, MI_COUNT }; int _visible_notes = 7; // updated in render(); used by clampScroll() @@ -22,16 +24,12 @@ class RingtoneEditorScreen : public UIScreen { int _slot; // 0=melody1, 1=melody2 int _cursor; int _scroll; - bool _menu_open; - int _menu_sel; - int _menu_scroll; + PopupMenu _menu; char _play_buf[220]; // persistent RTTTL buffer — library holds a pointer into it - - enum MenuOpt { - M_PLAY_STOP, M_SWITCH, M_DURATION, M_BPM_UP, M_BPM_DOWN, - M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT - }; - static const char* MENU_LABELS[M_COUNT]; + char _menu_play_label[8]; + char _menu_slot_label[16]; + char _menu_dur_label[16]; + char _menu_bpm_label[10]; static uint8_t notePitch(uint8_t b) { return b & 0x07; } static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } @@ -68,11 +66,26 @@ public: uint8_t rlen = _prefs ? (s2 ? _prefs->ringtone2_len : _prefs->ringtone_len) : 0; _len = (rlen <= (uint8_t)MAX_NOTES) ? rlen : 0; if (_prefs) memcpy(_notes, s2 ? _prefs->ringtone2_notes : _prefs->ringtone_notes, sizeof(_notes)); - _cursor = 0; - _scroll = 0; - _menu_open = false; - _menu_sel = 0; - _menu_scroll = 0; + _cursor = 0; + _scroll = 0; + _menu.active = false; + } + + void openMenu() { + snprintf(_menu_play_label, sizeof(_menu_play_label), "%s", _task->isMelodyPlaying() ? "Stop" : "Play"); + snprintf(_menu_slot_label, sizeof(_menu_slot_label), "Melody %d", (_slot == 0) ? 2 : 1); + uint8_t di = (_cursor < _len) ? noteDurIdx(_notes[_cursor]) : 0; + snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]); + snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]); + _menu.begin("Options", 5); + _menu.addItem(_menu_play_label); + _menu.addItem(_menu_slot_label); + _menu.addItem(_menu_dur_label); + _menu.addItem(_menu_bpm_label); + _menu.addItem("Insert"); + _menu.addItem("Delete"); + _menu.addItem("Save & Exit"); + _menu.addItem("Discard"); } int render(DisplayDriver& display) override { @@ -144,34 +157,7 @@ public: display.setCursor(0, bottom_y); display.print("ENT:oct MENU:opts"); - if (_menu_open) { - const int menu_ih = display.lineStep(); - const int mw = 100, mh = MENU_VISIBLE * menu_ih + 4; - int mx = (display.width() - mw) / 2; - int my = (display.height() - mh) / 2; - display.setColor(DisplayDriver::DARK); - display.fillRect(mx, my, mw, mh); - display.setColor(DisplayDriver::LIGHT); - display.drawRect(mx, my, mw, mh); - for (int i = 0; i < MENU_VISIBLE; i++) { - int item = _menu_scroll + i; - if (item >= M_COUNT) break; - int iy = my + 2 + i * menu_ih; - bool sel = (item == _menu_sel); - display.drawSelectionRow(mx + 1, iy - 1, mw - 2, menu_ih, sel); - display.setCursor(mx + 4, iy); - if (item == M_PLAY_STOP) - display.print(_task->isMelodyPlaying() ? "Stop" : "Play"); - else if (item == M_SWITCH) - display.print(_slot == 0 ? "Edit Melody 2" : "Edit Melody 1"); - else - display.print(MENU_LABELS[item]); - display.setColor(DisplayDriver::LIGHT); - } - int cw = display.getCharWidth(); - if (_menu_scroll > 0) { display.setCursor(mx + mw - cw - 1, my + 2); display.print("^"); } - if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - cw - 1, my + mh - menu_ih); display.print("v"); } - } + if (_menu.active) _menu.render(display); return 200; } @@ -185,90 +171,84 @@ public: bool menu_key = (c == KEY_CONTEXT_MENU); bool cancel = (c == KEY_CANCEL); - if (_menu_open) { - if (cancel) { _menu_open = false; return true; } - if (up && _menu_sel > 0) { - _menu_sel--; - if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel; + if (_menu.active) { + // LEFT/RIGHT cycle Duration and BPM in-place, menu stays open. + if (left || right) { + int sel = _menu.selectedIndex(); + if (sel == MI_DURATION && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + di = right ? (di + 1) & 0x03 : (di + 3) & 0x03; + _notes[_cursor] = packNote(p, o, di); + snprintf(_menu_dur_label, sizeof(_menu_dur_label), "Duration: %s", DUR_LABELS[di]); + } else if (sel == MI_BPM) { + if (right && _bpm_idx < 4) _bpm_idx++; + else if (left && _bpm_idx > 0) _bpm_idx--; + snprintf(_menu_bpm_label, sizeof(_menu_bpm_label), "BPM: %u", BPM_OPTS[_bpm_idx]); + } return true; } - if (down && _menu_sel < M_COUNT - 1) { - _menu_sel++; - if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1; - return true; - } - if (!enter) return true; - - _menu_open = false; - switch ((MenuOpt)_menu_sel) { - case M_PLAY_STOP: - if (_task->isMelodyPlaying()) { + auto res = _menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + switch ((MenuIdx)_menu.selectedIndex()) { + case MI_PLAY: + if (_task->isMelodyPlaying()) { _task->stopMelody(); } + else if (_len > 0) { buildRTTTL(); _task->playMelody(_play_buf); } + break; + case MI_SWITCH: _task->stopMelody(); - } else if (_len > 0) { - buildRTTTL(); - _task->playMelody(_play_buf); - } - break; - case M_SWITCH: - _task->stopMelody(); - this->enter(1 - _slot); - break; - case M_DURATION: - if (_cursor < _len) { - uint8_t p = notePitch(_notes[_cursor]); - uint8_t o = noteOctave(_notes[_cursor]); - uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03; - _notes[_cursor] = packNote(p, o, di); - } - break; - case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break; - case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break; - case M_INSERT: - if (_len < MAX_NOTES) { - int ins = (_cursor < _len) ? _cursor + 1 : _cursor; - for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; - _notes[ins] = packNote(1, 5, 1); - _len++; - _cursor = ins; - clampScroll(); - } - break; - case M_DELETE: - if (_len > 0 && _cursor < _len) { - for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; - _len--; - if (_cursor >= _len && _len > 0) _cursor = _len - 1; - else if (_len == 0) _cursor = 0; - clampScroll(); - } - break; - case M_SAVE: - if (_prefs) { - if (_slot == 1) { - _prefs->ringtone2_bpm_idx = _bpm_idx; - _prefs->ringtone2_len = _len; - memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes)); - } else { - _prefs->ringtone_bpm_idx = _bpm_idx; - _prefs->ringtone_len = _len; - memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + this->enter(1 - _slot); + break; + case MI_DURATION: break; // already handled by LEFT/RIGHT + case MI_BPM: break; // already handled by LEFT/RIGHT + case MI_INSERT: + if (_len < MAX_NOTES) { + int ins = (_cursor < _len) ? _cursor + 1 : _cursor; + for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; + _notes[ins] = packNote(1, 5, 1); + _len++; + _cursor = ins; + clampScroll(); } - the_mesh.savePrefs(); - } - _task->stopMelody(); - _task->gotoToolsScreen(); - break; - case M_DISCARD: - _task->stopMelody(); - _task->gotoToolsScreen(); - break; - default: break; + break; + case MI_DELETE: + if (_len > 0 && _cursor < _len) { + for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; + _len--; + if (_cursor >= _len && _len > 0) _cursor = _len - 1; + else if (_len == 0) _cursor = 0; + clampScroll(); + } + break; + case MI_SAVE: + if (_prefs) { + if (_slot == 1) { + _prefs->ringtone2_bpm_idx = _bpm_idx; + _prefs->ringtone2_len = _len; + memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes)); + } else { + _prefs->ringtone_bpm_idx = _bpm_idx; + _prefs->ringtone_len = _len; + memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + } + the_mesh.savePrefs(); + } + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + case MI_DISCARD: + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + default: break; + } } return true; } if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; } - if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; } + if (menu_key) { openMenu(); return true; } if (left && _cursor > 0) { _cursor--; clampScroll(); return true; } if (right) { @@ -316,6 +296,3 @@ const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 }; const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 }; const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; -const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = { - "Play/Stop", "Switch Melody", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" -}; diff --git a/examples/companion_radio/ui-new/TrailScreen.h b/examples/companion_radio/ui-new/TrailScreen.h index 3d7f6df2..61ede5e9 100644 --- a/examples/companion_radio/ui-new/TrailScreen.h +++ b/examples/companion_radio/ui-new/TrailScreen.h @@ -80,6 +80,7 @@ public: int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1; if (_act_map[idx] == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } if (_act_map[idx] == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; } + if (_act_map[idx] == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; } } return true; // swallow on action rows } @@ -229,10 +230,10 @@ private: _act_map[_act_count++] = ACT_LOAD; _action_menu.addItem("Load trail"); } if (!_store->empty()) { - _act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export GPX (live)"); + _act_map[_act_count++] = ACT_EXPORT; _action_menu.addItem("Export (live)"); } if (saved) { - _act_map[_act_count++] = ACT_EXPORT_SAVED; _action_menu.addItem("Export GPX (saved)"); + _act_map[_act_count++] = ACT_EXPORT_SAVED; _action_menu.addItem("Export (saved)"); } if (!_store->empty()) { _act_map[_act_count++] = ACT_RESET; _action_menu.addItem("Reset trail");