diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a165f9c..0acbc08 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 8 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "27 Feb 2026" +#define FIRMWARE_BUILD_DATE "1 March 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.9.5" +#define FIRMWARE_VERSION "Meck v0.9.6" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 7d729b3..7cf3475 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -415,6 +415,7 @@ static uint32_t _atoi(const char* sp) { /* GLOBAL OBJECTS */ #ifdef DISPLAY_CLASS #include "UITask.h" + #include "MapScreen.h" // After BLE — PNGdec headers conflict with BLE if included earlier UITask ui_task(&board, &serial_interface); #endif @@ -826,6 +827,35 @@ void loop() { sensors.loop(); + // Map screen: periodically update own GPS position and contact markers + if (ui_task.isOnMapScreen()) { + static unsigned long lastMapUpdate = 0; + if (millis() - lastMapUpdate > 30000) { // Every 30 seconds + lastMapUpdate = millis(); + MapScreen* ms = (MapScreen*)ui_task.getMapScreen(); + if (ms) { + // Update own GPS position only when GPS hardware is active + #if HAS_GPS + if (gpsDuty.isHardwareOn()) { + ms->updateGPSPosition(sensors.node_lat, sensors.node_lon); + } + #endif + + // Always refresh contact markers (new contacts arrive via radio) + ms->clearMarkers(); + ContactsIterator it = the_mesh.startContactsIterator(); + ContactInfo ci; + while (it.hasNext(&the_mesh, ci)) { + if (ci.gps_lat != 0 || ci.gps_lon != 0) { + double lat = ((double)ci.gps_lat) / 1000000.0; + double lon = ((double)ci.gps_lon) / 1000000.0; + ms->addMarker(lat, lon); + } + } + } + } + } + // CPU frequency auto-timeout back to idle cpuPower.loop(); @@ -1720,6 +1750,39 @@ void handleKeyboardInput() { break; #endif + case 'g': + // Open map screen, or re-center on GPS if already on map + if (ui_task.isOnMapScreen()) { + ui_task.injectKey('g'); // Re-center on GPS + } else { + Serial.println("Opening map"); + { + MapScreen* ms = (MapScreen*)ui_task.getMapScreen(); + if (ms) { + ms->setSDReady(sdCardReady); + ms->setGPSPosition(sensors.node_lat, + sensors.node_lon); + // Populate contact markers via iterator + ms->clearMarkers(); + ContactsIterator it = the_mesh.startContactsIterator(); + ContactInfo ci; + int markerCount = 0; + while (it.hasNext(&the_mesh, ci)) { + if (ci.gps_lat != 0 || ci.gps_lon != 0) { + double lat = ((double)ci.gps_lat) / 1000000.0; + double lon = ((double)ci.gps_lon) / 1000000.0; + ms->addMarker(lat, lon); + markerCount++; + Serial.printf(" marker: %s @ %.4f,%.4f\n", ci.name, lat, lon); + } + } + Serial.printf("MapScreen: %d contacts with GPS position\n", markerCount); + } + } + ui_task.gotoMapScreen(); + } + break; + case 'n': // Open notes Serial.println("Opening notes"); @@ -1735,11 +1798,12 @@ void handleKeyboardInput() { break; case 's': - // Open settings (from home), or navigate down on channel/contacts/admin/web + // Open settings (from home), or navigate down on channel/contacts/admin/web/map if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif + || ui_task.isOnMapScreen() ) { ui_task.injectKey('s'); // Pass directly for scrolling } else { @@ -1754,6 +1818,7 @@ void handleKeyboardInput() { #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif + || ui_task.isOnMapScreen() ) { ui_task.injectKey('w'); // Pass directly for scrolling } else { @@ -1764,7 +1829,7 @@ void handleKeyboardInput() { case 'a': // Navigate left or switch channel (on channel screen) - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) { ui_task.injectKey('a'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Previous"); @@ -1774,7 +1839,7 @@ void handleKeyboardInput() { case 'd': // Navigate right or switch channel (on channel screen) - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()) { ui_task.injectKey('d'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Next"); @@ -1863,9 +1928,18 @@ void handleKeyboardInput() { } break; + case 'z': + // Zoom in on map screen + if (ui_task.isOnMapScreen()) { + ui_task.injectKey('z'); + } + break; + case 'x': - // Export contacts to SD card (contacts screen only) - if (ui_task.isOnContactsScreen()) { + // Zoom out on map screen, or export contacts on contacts screen + if (ui_task.isOnMapScreen()) { + ui_task.injectKey('x'); + } else if (ui_task.isOnContactsScreen()) { Serial.println("Contacts: Exporting to SD..."); int exported = exportContactsToSD(); if (exported >= 0) { @@ -1957,6 +2031,11 @@ void handleKeyboardInput() { break; } #endif + // Pass unhandled keys to map screen (+, -, i, o for zoom) + if (ui_task.isOnMapScreen()) { + ui_task.injectKey(key); + break; + } Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key); break; } diff --git a/examples/companion_radio/ui-new/Mapscreen.h b/examples/companion_radio/ui-new/Mapscreen.h new file mode 100644 index 0000000..4e8d4b4 --- /dev/null +++ b/examples/companion_radio/ui-new/Mapscreen.h @@ -0,0 +1,794 @@ +#pragma once + +// ============================================================================= +// MapScreen — OSM Tile Map for T-Deck Pro E-Ink Display +// ============================================================================= +// +// Renders standard OSM "slippy map" PNG tiles from SD card onto the e-ink +// display at native 240×320 resolution (bypassing the 128×128 logical grid). +// +// Tiles are B&W PNGs stored at /tiles/{zoom}/{x}/{y}.png — the same format +// used by Ripple, tdeck-maps, and MTD-Script tile downloaders. +// +// REQUIREMENTS: +// 1. Add PNGdec library to platformio.ini: +// lib_deps = ... bitbank2/PNGdec@^1.0.1 +// +// 2. Add raw display access to GxEPDDisplay.h (public section): +// // --- Raw pixel access for MapScreen (bypasses scaling) --- +// void drawPixelRaw(int16_t x, int16_t y, uint16_t color) { +// display.drawPixel(x, y, color); +// } +// int16_t rawWidth() { return display.width(); } +// int16_t rawHeight() { return display.height(); } +// // Force endFrame() to push to display even if CRC unchanged +// // (needed because drawPixelRaw bypasses CRC tracking) +// void invalidateFrameCRC() { last_display_crc_value = 0; } +// +// 3. Add to UITask.h: +// #include "MapScreen.h" +// UIScreen* map_screen; +// void gotoMapScreen(); +// bool isOnMapScreen() const { return curr == map_screen; } +// UIScreen* getMapScreen() const { return map_screen; } +// +// 4. Initialise in UITask::begin(): +// map_screen = new MapScreen(this); +// +// 5. Implement UITask::gotoMapScreen() following gotoTextReader() pattern. +// +// 6. Hook 'g' key in main.cpp for GPS/Map access: +// case 'g': +// if (ui_task.isOnMapScreen()) { +// // Already on map — 'g' re-centers on GPS +// ui_task.injectKey('g'); +// } else { +// Serial.println("Opening map"); +// { +// MapScreen* ms = (MapScreen*)ui_task.getMapScreen(); +// if (ms) { +// ms->setSDReady(sdCardReady); +// ms->setGPSPosition(sensors.node_lat, +// sensors.node_lon); +// // Populate contact markers via iterator +// ms->clearMarkers(); +// ContactsIterator it = the_mesh.startContactsIterator(); +// ContactInfo ci; +// while (it.hasNext(&the_mesh, ci)) { +// double lat = ((double)ci.gps_lat) / 1000000.0; +// double lon = ((double)ci.gps_lon) / 1000000.0; +// ms->addMarker(lat, lon); +// } +// } +// } +// ui_task.gotoMapScreen(); +// } +// break; +// +// 7. Route WASD/zoom keys to map screen in main.cpp (in existing handlers): +// For 'w', 's', 'a', 'd' cases, add: +// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; } +// For the default case, add map screen passthrough: +// if (ui_task.isOnMapScreen()) { ui_task.injectKey(key); break; } +// This covers +, -, i, o, g (re-center) keys too. +// +// TILE SOURCES (B&W recommended for e-ink): +// - MTD-Script: github.com/fistulareffigy/MTD-Script +// - tdeck-maps: github.com/JustDr00py/tdeck-maps +// - Stamen Toner style gives best e-ink contrast +// ============================================================================= + +#include +#include +#include +#undef local // PNGdec's zutil.h defines 'local' as 'static' — breaks any variable named 'local' +#include +#include +#include + +// --------------------------------------------------------------------------- +// Layout constants (physical pixel coordinates, 240×320 display) +// --------------------------------------------------------------------------- +#define MAP_DISPLAY_W 240 +#define MAP_DISPLAY_H 320 + +// Footer bar occupies the bottom — matches other screens' setTextSize(1) footer +#define MAP_FOOTER_H 24 // ~24px at bottom for nav hints +#define MAP_VIEWPORT_Y 0 // Map starts at top +#define MAP_VIEWPORT_H (MAP_DISPLAY_H - MAP_FOOTER_H) // 296px for map + +#define MAP_TILE_SIZE 256 // Standard OSM tile size in pixels +#define MAP_DEFAULT_ZOOM 13 +#define MAP_MIN_ZOOM 1 +#define MAP_MAX_ZOOM 17 + +// PNG decode buffer size — 256×256 RGB = 196KB, but PNGdec streams row-by-row +// We only need a line buffer. Allocate in PSRAM for safety. +#define MAP_PNG_BUF_SIZE (65536) // 64KB for PNG file read buffer + +// Tile path on SD card +#define MAP_TILE_ROOT "/tiles" + +// Pan step: fraction of viewport to move per keypress +#define MAP_PAN_FRACTION 4 // 1/4 of viewport per press + +// Contact marker size (pixels) +#define MAP_MARKER_SIZE 7 // 7×7 diamond marker +#define MAP_MAX_MARKERS 32 // Max contact markers (matches MAX_CONTACTS) + + +class MapScreen : public UIScreen { +public: + MapScreen(UITask* task) + : _task(task), + _einkDisplay(nullptr), + _sdReady(false), + _needsRedraw(true), + _hasFix(false), + _centerLat(-33.8688), // Default: Sydney (most Ripple users) + _centerLon(151.2093), + _gpsLat(0.0), + _gpsLon(0.0), + _zoom(MAP_DEFAULT_ZOOM), + _zoomMin(MAP_MIN_ZOOM), + _zoomMax(MAP_MAX_ZOOM), + _pngBuf(nullptr), + _tileFound(false) + {} + + ~MapScreen() { + if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; } + } + + void setSDReady(bool ready) { _sdReady = ready; } + + // Set initial GPS position (called when opening map — centers viewport) + void setGPSPosition(double lat, double lon) { + if (lat != 0.0 || lon != 0.0) { + _gpsLat = lat; + _gpsLon = lon; + _centerLat = lat; + _centerLon = lon; + _hasFix = true; + _needsRedraw = true; + } + } + + // Update own GPS position without moving viewport (called periodically) + void updateGPSPosition(double lat, double lon) { + if (lat == 0.0 && lon == 0.0) return; + if (lat != _gpsLat || lon != _gpsLon) { + _gpsLat = lat; + _gpsLon = lon; + _hasFix = true; + _needsRedraw = true; // Redraw to move own-position marker + } + } + + // Add a location marker (call once per contact before entering map) + void clearMarkers() { _numMarkers = 0; } + void addMarker(double lat, double lon) { + if (_numMarkers >= MAP_MAX_MARKERS) return; + if (lat == 0.0 && lon == 0.0) return; // Skip no-location contacts + _markers[_numMarkers].lat = lat; + _markers[_numMarkers].lon = lon; + _numMarkers++; + } + + // Refresh contact markers (called periodically from main loop) + // Clears and rebuilds — caller iterates contacts and calls addMarker() + int getNumMarkers() const { return _numMarkers; } + + // Called when navigating to map screen + void enter(DisplayDriver& display) { + _einkDisplay = static_cast(&display); + _needsRedraw = true; + + // Allocate PNG read buffer in PSRAM on first use + if (!_pngBuf) { + _pngBuf = (uint8_t*)ps_malloc(MAP_PNG_BUF_SIZE); + if (!_pngBuf) { + Serial.println("MapScreen: PSRAM alloc failed, trying heap"); + _pngBuf = (uint8_t*)malloc(MAP_PNG_BUF_SIZE); + } + if (_pngBuf) { + Serial.printf("MapScreen: PNG buffer allocated (%d bytes)\n", MAP_PNG_BUF_SIZE); + } else { + Serial.println("MapScreen: PNG buffer alloc FAILED"); + } + } + + // Detect available zoom levels from SD card directories + detectZoomRange(); + } + + // ---- UIScreen interface ---- + + int render(DisplayDriver& display) override { + if (!_einkDisplay) { + _einkDisplay = static_cast(&display); + } + + if (!_sdReady) { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(10, 20); + display.print("SD card not found"); + display.setCursor(10, 35); + display.print("Insert SD with"); + display.setCursor(10, 48); + display.print("/tiles/{z}/{x}/{y}.png"); + return 5000; + } + + // Always render tiles — UITask clears the buffer via startFrame() before + // calling us, so we must redraw every time (e.g. after alert overlays) + bool wasRedraw = _needsRedraw; + _needsRedraw = false; + + // Render map tiles into the viewport + renderMapViewport(); + + // Overlay contact markers + renderContactMarkers(); + + // Crosshair at viewport center + renderCrosshair(); + + // Footer bar (uses normal display API with scaling) + renderFooter(display); + + // Raw pixel writes bypass CRC tracking — force refresh + _einkDisplay->invalidateFrameCRC(); + + // If user panned/zoomed, allow quick re-render; otherwise idle longer + return wasRedraw ? 1000 : 30000; + } + + bool handleInput(char c) override { + // Pan distances in degrees — adaptive to zoom level + // At zoom Z, one tile covers 360/2^Z degrees of longitude + double tileLonSpan = 360.0 / (1 << _zoom); + double tileLatSpan = tileLonSpan * cos(_centerLat * PI / 180.0); // Rough approx + + // Pan by 1/MAP_PAN_FRACTION of viewport (viewport ≈ 1 tile) + double panLon = tileLonSpan / MAP_PAN_FRACTION; + double panLat = tileLatSpan / MAP_PAN_FRACTION; + + switch (c) { + // ---- WASD panning ---- + case 'w': + case 'W': + _centerLat += panLat; + if (_centerLat > 85.05) _centerLat = 85.05; // Web Mercator limit + _needsRedraw = true; + return true; + + case 's': + case 'S': + _centerLat -= panLat; + if (_centerLat < -85.05) _centerLat = -85.05; + _needsRedraw = true; + return true; + + case 'a': + case 'A': + _centerLon -= panLon; + if (_centerLon < -180.0) _centerLon += 360.0; + _needsRedraw = true; + return true; + + case 'd': + case 'D': + _centerLon += panLon; + if (_centerLon > 180.0) _centerLon -= 360.0; + _needsRedraw = true; + return true; + + // ---- Zoom controls ---- + case 'z': + case 'Z': + if (_zoom < _zoomMax) { + _zoom++; + _needsRedraw = true; + Serial.printf("MapScreen: zoom in -> %d\n", _zoom); + } + return true; + + case 'x': + case 'X': + if (_zoom > _zoomMin) { + _zoom--; + _needsRedraw = true; + Serial.printf("MapScreen: zoom out -> %d\n", _zoom); + } + return true; + + // ---- Re-center on GPS fix ---- + case 'g': + if (_hasFix) { + _centerLat = _gpsLat; + _centerLon = _gpsLon; + _needsRedraw = true; + Serial.println("MapScreen: re-center on GPS"); + } + return true; + + default: + return false; + } + } + +private: + UITask* _task; + GxEPDDisplay* _einkDisplay; + bool _sdReady; + bool _needsRedraw; + bool _hasFix; + + // Map state + double _centerLat; + double _centerLon; + double _gpsLat; // Own GPS position (separate from viewport center) + double _gpsLon; + int _zoom; + int _zoomMin; // Detected from SD card + int _zoomMax; // Detected from SD card + + // PNG decode buffer (PSRAM) + uint8_t* _pngBuf; + bool _tileFound; // Did last tile load succeed? + + // PNGdec instance + PNG _png; + + // Contacts for marker overlay + struct MapMarker { double lat; double lon; }; + MapMarker _markers[MAP_MAX_MARKERS]; + int _numMarkers = 0; + + // ---- Rendering state passed to PNG callback ---- + // PNGdec calls our callback per scanline — we need to know where to draw. + // Also carries a PNG* so the static callback can call getLineAsRGB565(). + struct DrawContext { + GxEPDDisplay* display; + PNG* png; // Pointer to the decoder (for getLineAsRGB565) + int offsetX; // Screen X offset for this tile + int offsetY; // Screen Y offset for this tile + int viewportY; // Top of viewport (MAP_VIEWPORT_Y) + int viewportH; // Height of viewport (MAP_VIEWPORT_H) + }; + DrawContext _drawCtx; + + // ========================================================================== + // Detect available zoom levels from /tiles/{z}/ directories on SD + // ========================================================================== + + void detectZoomRange() { + if (!_sdReady) return; + + _zoomMin = MAP_MAX_ZOOM; + _zoomMax = MAP_MIN_ZOOM; + + char path[32]; + for (int z = MAP_MIN_ZOOM; z <= MAP_MAX_ZOOM; z++) { + snprintf(path, sizeof(path), MAP_TILE_ROOT "/%d", z); + if (SD.exists(path)) { + if (z < _zoomMin) _zoomMin = z; + if (z > _zoomMax) _zoomMax = z; + } + } + + // If no tiles found, reset to defaults + if (_zoomMin > _zoomMax) { + _zoomMin = MAP_MIN_ZOOM; + _zoomMax = MAP_MAX_ZOOM; + Serial.println("MapScreen: no tile directories found"); + } else { + Serial.printf("MapScreen: detected zoom range %d-%d\n", _zoomMin, _zoomMax); + } + + // Clamp current zoom to available range + if (_zoom > _zoomMax) _zoom = _zoomMax; + if (_zoom < _zoomMin) _zoom = _zoomMin; + } + + // ========================================================================== + // Tile coordinate math (Web Mercator / Slippy Map convention) + // ========================================================================== + + // Convert lat/lon to tile X,Y and sub-tile pixel offset at given zoom + static void latLonToTileXY(double lat, double lon, int zoom, + int& tileX, int& tileY, + int& pixelX, int& pixelY) + { + int n = 1 << zoom; + + // Tile X (longitude is linear) + double x = (lon + 180.0) / 360.0 * n; + tileX = (int)floor(x); + pixelX = (int)((x - tileX) * MAP_TILE_SIZE); + + // Tile Y (latitude uses Mercator projection) + double latRad = lat * PI / 180.0; + double y = (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0 * n; + tileY = (int)floor(y); + pixelY = (int)((y - tileY) * MAP_TILE_SIZE); + } + + // Convert tile X,Y + pixel offset back to lat/lon + static void tileXYToLatLon(int tileX, int tileY, int pixelX, int pixelY, + int zoom, double& lat, double& lon) + { + int n = 1 << zoom; + double x = tileX + (double)pixelX / MAP_TILE_SIZE; + double y = tileY + (double)pixelY / MAP_TILE_SIZE; + + lon = x / n * 360.0 - 180.0; + double latRad = atan(sinh(PI * (1.0 - 2.0 * y / n))); + lat = latRad * 180.0 / PI; + } + + // Convert a lat/lon to pixel position within the current viewport + // Returns false if off-screen + bool latLonToScreen(double lat, double lon, int& screenX, int& screenY) { + int centerTileX, centerTileY, centerPixelX, centerPixelY; + latLonToTileXY(_centerLat, _centerLon, _zoom, + centerTileX, centerTileY, centerPixelX, centerPixelY); + + int targetTileX, targetTileY, targetPixelX, targetPixelY; + latLonToTileXY(lat, lon, _zoom, + targetTileX, targetTileY, targetPixelX, targetPixelY); + + // Calculate pixel delta from center + int dx = (targetTileX - centerTileX) * MAP_TILE_SIZE + (targetPixelX - centerPixelX); + int dy = (targetTileY - centerTileY) * MAP_TILE_SIZE + (targetPixelY - centerPixelY); + + screenX = MAP_DISPLAY_W / 2 + dx; + screenY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2 + dy; + + return (screenX >= 0 && screenX < MAP_DISPLAY_W && + screenY >= MAP_VIEWPORT_Y && screenY < MAP_VIEWPORT_Y + MAP_VIEWPORT_H); + } + + // ========================================================================== + // Tile loading and rendering + // ========================================================================== + + // Build tile file path: /tiles/{zoom}/{x}/{y}.png + static void buildTilePath(char* buf, int bufSize, int zoom, int x, int y) { + snprintf(buf, bufSize, MAP_TILE_ROOT "/%d/%d/%d.png", zoom, x, y); + } + + // Load a PNG tile from SD and decode it directly to the display + // screenX, screenY = top-left corner on display where this tile goes + bool loadAndRenderTile(int tileX, int tileY, int screenX, int screenY) { + if (!_pngBuf || !_einkDisplay) return false; + + char path[64]; + buildTilePath(path, sizeof(path), _zoom, tileX, tileY); + + // Check existence first to avoid noisy ESP32 VFS error logs + if (!SD.exists(path)) return false; + + File f = SD.open(path, FILE_READ); + if (!f) return false; + + // Read entire PNG into buffer + int fileSize = f.size(); + if (fileSize > MAP_PNG_BUF_SIZE) { + Serial.printf("MapScreen: tile too large: %s (%d bytes)\n", path, fileSize); + f.close(); + return false; + } + + int bytesRead = f.read(_pngBuf, fileSize); + f.close(); + + if (bytesRead != fileSize) { + Serial.printf("MapScreen: short read: %s (%d/%d)\n", path, bytesRead, fileSize); + return false; + } + + // Set up draw context for the PNG callback + _drawCtx.display = _einkDisplay; + _drawCtx.png = &_png; + _drawCtx.offsetX = screenX; + _drawCtx.offsetY = screenY; + _drawCtx.viewportY = MAP_VIEWPORT_Y; + _drawCtx.viewportH = MAP_VIEWPORT_H; + + // Open PNG from memory buffer + int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback); + if (rc != PNG_SUCCESS) { + Serial.printf("MapScreen: PNG open failed: %s (rc=%d)\n", path, rc); + return false; + } + + // Decode — triggers pngDrawCallback for each scanline. + // First arg is user pointer, passed as pDraw->pUser in callback. + rc = _png.decode(&_drawCtx, 0); + _png.close(); + + if (rc != PNG_SUCCESS) { + Serial.printf("MapScreen: PNG decode failed: %s (rc=%d)\n", path, rc); + return false; + } + + return true; + } + + // PNGdec scanline callback — called once per row of the decoded image. + // Draws directly to the e-ink display at raw pixel coordinates. + // Uses getLineAsRGB565 with correct (little) endianness for ESP32. + static int pngDrawCallback(PNGDRAW* pDraw) { + DrawContext* ctx = (DrawContext*)pDraw->pUser; + if (!ctx || !ctx->display || !ctx->png) return 0; + + int screenY = ctx->offsetY + pDraw->y; + + // Clip to viewport vertically + if (screenY < ctx->viewportY || screenY >= ctx->viewportY + ctx->viewportH) return 1; + + // Debug: log format on first row of first tile only + if (pDraw->y == 0 && ctx->offsetX >= 0 && ctx->offsetY >= 0) { + static bool logged = false; + if (!logged) { + Serial.printf("MapScreen: PNG iBpp=%d iWidth=%d\n", pDraw->iBpp, pDraw->iWidth); + logged = true; + } + } + + uint16_t lineWidth = pDraw->iWidth; + uint16_t lineBuf[MAP_TILE_SIZE]; + if (lineWidth > MAP_TILE_SIZE) lineWidth = MAP_TILE_SIZE; + ctx->png->getLineAsRGB565(pDraw, lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF); + + for (int x = 0; x < lineWidth; x++) { + int screenX = ctx->offsetX + x; + if (screenX < 0 || screenX >= MAP_DISPLAY_W) continue; + + // RGB565 little-endian on ESP32: standard bit layout + // R[15:11] G[10:5] B[4:0] + uint16_t pixel = lineBuf[x]; + + // For B&W tiles this is 0x0000 (black) or 0xFFFF (white) + // Simple threshold on full 16-bit value handles both cleanly + uint16_t color = (pixel > 0x7FFF) ? GxEPD_WHITE : GxEPD_BLACK; + ctx->display->drawPixelRaw(screenX, screenY, color); + } + return 1; + } + + // ========================================================================== + // Viewport rendering — stitch tiles to fill the screen + // ========================================================================== + + void renderMapViewport() { + if (!_einkDisplay) return; + + // Find which tile the center point falls in + int centerTileX, centerTileY, centerPixelX, centerPixelY; + latLonToTileXY(_centerLat, _centerLon, _zoom, + centerTileX, centerTileY, centerPixelX, centerPixelY); + + Serial.printf("MapScreen: center tile %d/%d/%d px(%d,%d)\n", + _zoom, centerTileX, centerTileY, centerPixelX, centerPixelY); + + // Screen position where the center tile's (0,0) corner should be placed + // such that the GPS point ends up at viewport center + int viewCenterX = MAP_DISPLAY_W / 2; + int viewCenterY = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2; + + int baseTileScreenX = viewCenterX - centerPixelX; + int baseTileScreenY = viewCenterY - centerPixelY; + + // Determine tile grid range needed to cover the entire viewport + int startDX = 0, startDY = 0; + int endDX = 0, endDY = 0; + + while (baseTileScreenX + startDX * MAP_TILE_SIZE > 0) startDX--; + while (baseTileScreenY + startDY * MAP_TILE_SIZE > MAP_VIEWPORT_Y) startDY--; + while (baseTileScreenX + (endDX + 1) * MAP_TILE_SIZE < MAP_DISPLAY_W) endDX++; + while (baseTileScreenY + (endDY + 1) * MAP_TILE_SIZE < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) endDY++; + + int maxTile = (1 << _zoom) - 1; + int loaded = 0, missing = 0; + + for (int dy = startDY; dy <= endDY; dy++) { + for (int dx = startDX; dx <= endDX; dx++) { + int tx = centerTileX + dx; + int ty = centerTileY + dy; + + // Longitude wraps + if (tx < 0) tx += (1 << _zoom); + if (tx > maxTile) tx -= (1 << _zoom); + + // Latitude doesn't wrap — skip out-of-range + if (ty < 0 || ty > maxTile) continue; + + int screenX = baseTileScreenX + dx * MAP_TILE_SIZE; + int screenY = baseTileScreenY + dy * MAP_TILE_SIZE; + + if (loadAndRenderTile(tx, ty, screenX, screenY)) { + loaded++; + } else { + missing++; + } + } + } + + Serial.printf("MapScreen: rendered %d tiles, %d missing\n", loaded, missing); + _tileFound = (loaded > 0); + } + + // ========================================================================== + // Contact marker overlay + // ========================================================================== + + void renderContactMarkers() { + if (!_einkDisplay) return; + + Serial.printf("MapScreen: rendering %d contact markers\n", _numMarkers); + + int visible = 0; + for (int i = 0; i < _numMarkers; i++) { + int sx, sy; + if (latLonToScreen(_markers[i].lat, _markers[i].lon, sx, sy)) { + drawDiamond(sx, sy, GxEPD_BLACK); + visible++; + } + } + + // Render own GPS position as a distinct marker (circle with crosshair) + if (_hasFix) { + int sx, sy; + if (latLonToScreen(_gpsLat, _gpsLon, sx, sy)) { + drawOwnPosition(sx, sy); + visible++; + } + } + + if (_numMarkers > 0 || _hasFix) { + Serial.printf("MapScreen: %d markers visible on screen\n", visible); + } + } + + // Draw a filled diamond marker for contacts/nodes + void drawDiamond(int cx, int cy, uint16_t color) { + int r = MAP_MARKER_SIZE / 2; // radius = 3 + + // Filled diamond + for (int dy = -r; dy <= r; dy++) { + int span = r - abs(dy); + for (int dx = -span; dx <= span; dx++) { + int px = cx + dx; + int py = cy + dy; + if (px >= 0 && px < MAP_DISPLAY_W && + py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) { + _einkDisplay->drawPixelRaw(px, py, color); + } + } + } + + // White outline for visibility on dark map areas + for (int dy = -(r + 1); dy <= (r + 1); dy++) { + int span = (r + 1) - abs(dy); + int innerSpan = r - abs(dy); + + for (int dx = -span; dx <= span; dx++) { + if (abs(dy) <= r && abs(dx) <= innerSpan) continue; + + int px = cx + dx; + int py = cy + dy; + if (px >= 0 && px < MAP_DISPLAY_W && + py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) { + _einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE); + } + } + } + } + + // Draw own-position marker: filled circle with crosshair arms + // Visually distinct from contact diamonds + void drawOwnPosition(int cx, int cy) { + int r = 5; // Circle radius + + // White background circle (clears map underneath) + for (int dy = -(r + 1); dy <= (r + 1); dy++) { + for (int dx = -(r + 1); dx <= (r + 1); dx++) { + if (dx * dx + dy * dy <= (r + 1) * (r + 1)) { + int px = cx + dx, py = cy + dy; + if (px >= 0 && px < MAP_DISPLAY_W && + py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) { + _einkDisplay->drawPixelRaw(px, py, GxEPD_WHITE); + } + } + } + } + + // Black circle outline + for (int dy = -r; dy <= r; dy++) { + for (int dx = -r; dx <= r; dx++) { + int d2 = dx * dx + dy * dy; + if (d2 >= (r - 1) * (r - 1) && d2 <= r * r) { + int px = cx + dx, py = cy + dy; + if (px >= 0 && px < MAP_DISPLAY_W && + py >= MAP_VIEWPORT_Y && py < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) { + _einkDisplay->drawPixelRaw(px, py, GxEPD_BLACK); + } + } + } + } + + // Black dot at center + _einkDisplay->drawPixelRaw(cx, cy, GxEPD_BLACK); + if (cx + 1 < MAP_DISPLAY_W) _einkDisplay->drawPixelRaw(cx + 1, cy, GxEPD_BLACK); + if (cx - 1 >= 0) _einkDisplay->drawPixelRaw(cx - 1, cy, GxEPD_BLACK); + if (cy + 1 < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) _einkDisplay->drawPixelRaw(cx, cy + 1, GxEPD_BLACK); + if (cy - 1 >= MAP_VIEWPORT_Y) _einkDisplay->drawPixelRaw(cx, cy - 1, GxEPD_BLACK); + } + + // ========================================================================== + // Crosshair at viewport center + // ========================================================================== + + void renderCrosshair() { + if (!_einkDisplay) return; + + int cx = MAP_DISPLAY_W / 2; + int cy = MAP_VIEWPORT_Y + MAP_VIEWPORT_H / 2; + int len = 6; // arm length in pixels + + // Draw thin crosshair: black line with white border for contrast + // Horizontal arm + for (int x = cx - len; x <= cx + len; x++) { + if (x >= 0 && x < MAP_DISPLAY_W) { + // White border pixels above and below + if (cy - 1 >= MAP_VIEWPORT_Y) + _einkDisplay->drawPixelRaw(x, cy - 1, GxEPD_WHITE); + if (cy + 1 < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) + _einkDisplay->drawPixelRaw(x, cy + 1, GxEPD_WHITE); + // Black center line + _einkDisplay->drawPixelRaw(x, cy, GxEPD_BLACK); + } + } + + // Vertical arm + for (int y = cy - len; y <= cy + len; y++) { + if (y >= MAP_VIEWPORT_Y && y < MAP_VIEWPORT_Y + MAP_VIEWPORT_H) { + if (cx - 1 >= 0) + _einkDisplay->drawPixelRaw(cx - 1, y, GxEPD_WHITE); + if (cx + 1 < MAP_DISPLAY_W) + _einkDisplay->drawPixelRaw(cx + 1, y, GxEPD_WHITE); + _einkDisplay->drawPixelRaw(cx, y, GxEPD_BLACK); + } + } + } + + // ========================================================================== + // Footer bar — zoom level, GPS status, navigation hints + // ========================================================================== + + void renderFooter(DisplayDriver& display) { + // Use the standard footer pattern: setTextSize(1) at height()-12 + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + int footerY = display.height() - 12; + + // Separator line + display.drawRect(0, footerY - 2, display.width(), 1); + + // Left: zoom level + char left[8]; + snprintf(left, sizeof(left), "Z%d", _zoom); + display.setCursor(0, footerY); + display.print(left); + + // Right: navigation hint + const char* right = "WASD:pan Z/X:zoom"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8601c53..4fa344d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3,6 +3,7 @@ #include "../MyMesh.h" #include "NotesScreen.h" #include "RepeaterAdminScreen.h" +#include "MapScreen.h" #include "target.h" #include "GPSDutyCycle.h" #ifdef WIFI_SSID @@ -335,16 +336,20 @@ public: y += 10; display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings "); y += 10; -#ifdef HAS_4G_MODEM - display.drawTextCentered(display.width() / 2, y, "[E] Reader [T] Phone "); -#elif defined(MECK_AUDIO_VARIANT) - display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks"); -#else - display.drawTextCentered(display.width() / 2, y, "[E] Reader "); -#endif -#ifdef MECK_WEB_READER + display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps "); y += 10; +#if defined(HAS_4G_MODEM) && defined(MECK_WEB_READER) + display.drawTextCentered(display.width() / 2, y, "[T] Phone [B] Browser "); +#elif defined(HAS_4G_MODEM) + display.drawTextCentered(display.width() / 2, y, "[T] Phone "); +#elif defined(MECK_AUDIO_VARIANT) && defined(MECK_WEB_READER) + display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks [B] Browser "); +#elif defined(MECK_AUDIO_VARIANT) + display.drawTextCentered(display.width() / 2, y, "[P] Audiobooks "); +#elif defined(MECK_WEB_READER) display.drawTextCentered(display.width() / 2, y, "[B] Browser "); +#else + y -= 10; // reclaim the row for standalone #endif y += 14; @@ -917,6 +922,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #ifdef HAS_4G_MODEM sms_screen = new SMSScreen(this); #endif + map_screen = new MapScreen(this); setCurrScreen(splash); } @@ -1542,6 +1548,19 @@ void UITask::gotoWebReader() { } #endif +void UITask::gotoMapScreen() { + MapScreen* map = (MapScreen*)map_screen; + if (_display != NULL) { + map->enter(*_display); + } + setCurrScreen(map_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) { if (repeater_admin && isOnRepeaterAdmin()) { ((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index de45563..1490428 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -30,6 +30,9 @@ #include "WebReaderScreen.h" #endif +// MapScreen.h included in UITask.cpp and main.cpp only (PNGdec headers +// conflict with BLE if pulled into the global include chain) + class UITask : public AbstractUITask { DisplayDriver* _display; SensorManager* _sensors; @@ -73,6 +76,7 @@ class UITask : public AbstractUITask { #ifdef MECK_WEB_READER UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required) #endif + UIScreen* map_screen; // Map tile screen (GPS + SD card tiles) UIScreen* curr; void userLedHandler(); @@ -104,6 +108,7 @@ public: void gotoOnboarding(); // Navigate to settings in onboarding mode void gotoAudiobookPlayer(); // Navigate to audiobook player void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin + void gotoMapScreen(); // Navigate to map tile screen #ifdef MECK_WEB_READER void gotoWebReader(); // Navigate to web reader (browser) #endif @@ -131,6 +136,7 @@ public: bool isOnSettingsScreen() const { return curr == settings_screen; } bool isOnAudiobookPlayer() const { return curr == audiobook_screen; } bool isOnRepeaterAdmin() const { return curr == repeater_admin; } + bool isOnMapScreen() const { return curr == map_screen; } #ifdef MECK_WEB_READER bool isOnWebReader() const { return curr == web_reader; } #endif @@ -174,6 +180,7 @@ public: UIScreen* getAudiobookScreen() const { return audiobook_screen; } void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; } UIScreen* getRepeaterAdminScreen() const { return repeater_admin; } + UIScreen* getMapScreen() const { return map_screen; } #ifdef MECK_WEB_READER UIScreen* getWebReaderScreen() const { return web_reader; } #endif diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 1a04cc2..f41c966 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -12,7 +12,31 @@ #include #include #include -#include + +// Inline CRC32 for frame change detection (replaces bakercp/CRC32 +// to avoid naming collision with PNGdec's bundled CRC32.h) +class FrameCRC32 { + uint32_t _crc = 0xFFFFFFFF; +public: + void reset() { _crc = 0xFFFFFFFF; } + template void update(T val) { + const uint8_t* p = (const uint8_t*)&val; + for (size_t i = 0; i < sizeof(T); i++) { + _crc ^= p[i]; + for (int b = 0; b < 8; b++) + _crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1)); + } + } + template void update(const T* data, size_t len) { + const uint8_t* p = (const uint8_t*)data; + for (size_t i = 0; i < len * sizeof(T); i++) { + _crc ^= p[i]; + for (int b = 0; b < 8; b++) + _crc = (_crc >> 1) ^ (0xEDB88320 & -(int32_t)(_crc & 1)); + } + } + uint32_t finalize() { return _crc ^ 0xFFFFFFFF; } +}; #include "DisplayDriver.h" @@ -34,7 +58,7 @@ class GxEPDDisplay : public DisplayDriver { bool _init = false; bool _isOn = false; uint16_t _curr_color; - CRC32 display_crc; + FrameCRC32 display_crc; int last_display_crc_value = 0; public: @@ -60,4 +84,15 @@ public: void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; uint16_t getTextWidth(const char* str) override; void endFrame() override; -}; + + // --- Raw pixel access for MapScreen (bypasses scaling) --- + void drawPixelRaw(int16_t x, int16_t y, uint16_t color) { + display.drawPixel(x, y, color); + } + int16_t rawWidth() { return display.width(); } + int16_t rawHeight() { return display.height(); } + + // Force endFrame() to push to display even if CRC unchanged + // (needed because drawPixelRaw bypasses CRC tracking) + void invalidateFrameCRC() { last_display_crc_value = 0; } +}; \ No newline at end of file diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 0f81e1f..fa5a607 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -89,7 +89,7 @@ lib_deps = ${sensor_base.lib_deps} zinggjm/GxEPD2@^1.5.9 adafruit/Adafruit GFX Library@^1.11.0 - bakercp/CRC32@^2.0.0 + bitbank2/PNGdec@^1.0.1 ; --------------------------------------------------------------------------- ; Meck unified builds — one codebase, three variants via build flags @@ -157,7 +157,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 - -D FIRMWARE_VERSION='"Meck v0.9.5.4G"' + -D FIRMWARE_VERSION='"Meck v0.9.6.4G"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + + @@ -183,7 +183,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 - -D FIRMWARE_VERSION='"Meck v0.9.5.4G.SA"' + -D FIRMWARE_VERSION='"Meck v0.9.6.4G.SA"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + +