mirror of
https://github.com/pelgraine/Meck.git
synced 2026-08-10 18:52:44 +02:00
maps! version 1 - g key to access
This commit is contained in:
@@ -8,11 +8,11 @@
|
|||||||
#define FIRMWARE_VER_CODE 8
|
#define FIRMWARE_VER_CODE 8
|
||||||
|
|
||||||
#ifndef FIRMWARE_BUILD_DATE
|
#ifndef FIRMWARE_BUILD_DATE
|
||||||
#define FIRMWARE_BUILD_DATE "27 Feb 2026"
|
#define FIRMWARE_BUILD_DATE "1 March 2026"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef FIRMWARE_VERSION
|
#ifndef FIRMWARE_VERSION
|
||||||
#define FIRMWARE_VERSION "Meck v0.9.5"
|
#define FIRMWARE_VERSION "Meck v0.9.6"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ static uint32_t _atoi(const char* sp) {
|
|||||||
/* GLOBAL OBJECTS */
|
/* GLOBAL OBJECTS */
|
||||||
#ifdef DISPLAY_CLASS
|
#ifdef DISPLAY_CLASS
|
||||||
#include "UITask.h"
|
#include "UITask.h"
|
||||||
|
#include "MapScreen.h" // After BLE — PNGdec headers conflict with BLE if included earlier
|
||||||
UITask ui_task(&board, &serial_interface);
|
UITask ui_task(&board, &serial_interface);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -826,6 +827,35 @@ void loop() {
|
|||||||
|
|
||||||
sensors.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
|
// CPU frequency auto-timeout back to idle
|
||||||
cpuPower.loop();
|
cpuPower.loop();
|
||||||
|
|
||||||
@@ -1720,6 +1750,39 @@ void handleKeyboardInput() {
|
|||||||
break;
|
break;
|
||||||
#endif
|
#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':
|
case 'n':
|
||||||
// Open notes
|
// Open notes
|
||||||
Serial.println("Opening notes");
|
Serial.println("Opening notes");
|
||||||
@@ -1735,11 +1798,12 @@ void handleKeyboardInput() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 's':
|
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()
|
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()
|
||||||
#ifdef MECK_WEB_READER
|
#ifdef MECK_WEB_READER
|
||||||
|| ui_task.isOnWebReader()
|
|| ui_task.isOnWebReader()
|
||||||
#endif
|
#endif
|
||||||
|
|| ui_task.isOnMapScreen()
|
||||||
) {
|
) {
|
||||||
ui_task.injectKey('s'); // Pass directly for scrolling
|
ui_task.injectKey('s'); // Pass directly for scrolling
|
||||||
} else {
|
} else {
|
||||||
@@ -1754,6 +1818,7 @@ void handleKeyboardInput() {
|
|||||||
#ifdef MECK_WEB_READER
|
#ifdef MECK_WEB_READER
|
||||||
|| ui_task.isOnWebReader()
|
|| ui_task.isOnWebReader()
|
||||||
#endif
|
#endif
|
||||||
|
|| ui_task.isOnMapScreen()
|
||||||
) {
|
) {
|
||||||
ui_task.injectKey('w'); // Pass directly for scrolling
|
ui_task.injectKey('w'); // Pass directly for scrolling
|
||||||
} else {
|
} else {
|
||||||
@@ -1764,7 +1829,7 @@ void handleKeyboardInput() {
|
|||||||
|
|
||||||
case 'a':
|
case 'a':
|
||||||
// Navigate left or switch channel (on channel screen)
|
// 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
|
ui_task.injectKey('a'); // Pass directly for channel/contacts switching
|
||||||
} else {
|
} else {
|
||||||
Serial.println("Nav: Previous");
|
Serial.println("Nav: Previous");
|
||||||
@@ -1774,7 +1839,7 @@ void handleKeyboardInput() {
|
|||||||
|
|
||||||
case 'd':
|
case 'd':
|
||||||
// Navigate right or switch channel (on channel screen)
|
// 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
|
ui_task.injectKey('d'); // Pass directly for channel/contacts switching
|
||||||
} else {
|
} else {
|
||||||
Serial.println("Nav: Next");
|
Serial.println("Nav: Next");
|
||||||
@@ -1863,9 +1928,18 @@ void handleKeyboardInput() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'z':
|
||||||
|
// Zoom in on map screen
|
||||||
|
if (ui_task.isOnMapScreen()) {
|
||||||
|
ui_task.injectKey('z');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'x':
|
case 'x':
|
||||||
// Export contacts to SD card (contacts screen only)
|
// Zoom out on map screen, or export contacts on contacts screen
|
||||||
if (ui_task.isOnContactsScreen()) {
|
if (ui_task.isOnMapScreen()) {
|
||||||
|
ui_task.injectKey('x');
|
||||||
|
} else if (ui_task.isOnContactsScreen()) {
|
||||||
Serial.println("Contacts: Exporting to SD...");
|
Serial.println("Contacts: Exporting to SD...");
|
||||||
int exported = exportContactsToSD();
|
int exported = exportContactsToSD();
|
||||||
if (exported >= 0) {
|
if (exported >= 0) {
|
||||||
@@ -1957,6 +2031,11 @@ void handleKeyboardInput() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
#endif
|
#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);
|
Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <Arduino.h>
|
||||||
|
#include <SD.h>
|
||||||
|
#include <PNGdec.h>
|
||||||
|
#undef local // PNGdec's zutil.h defines 'local' as 'static' — breaks any variable named 'local'
|
||||||
|
#include <helpers/ui/UIScreen.h>
|
||||||
|
#include <helpers/ui/DisplayDriver.h>
|
||||||
|
#include <helpers/ui/GxEPDDisplay.h>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<GxEPDDisplay*>(&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<GxEPDDisplay*>(&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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "../MyMesh.h"
|
#include "../MyMesh.h"
|
||||||
#include "NotesScreen.h"
|
#include "NotesScreen.h"
|
||||||
#include "RepeaterAdminScreen.h"
|
#include "RepeaterAdminScreen.h"
|
||||||
|
#include "MapScreen.h"
|
||||||
#include "target.h"
|
#include "target.h"
|
||||||
#include "GPSDutyCycle.h"
|
#include "GPSDutyCycle.h"
|
||||||
#ifdef WIFI_SSID
|
#ifdef WIFI_SSID
|
||||||
@@ -335,16 +336,20 @@ public:
|
|||||||
y += 10;
|
y += 10;
|
||||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings ");
|
||||||
y += 10;
|
y += 10;
|
||||||
#ifdef HAS_4G_MODEM
|
display.drawTextCentered(display.width() / 2, y, "[E] Reader [G] Maps ");
|
||||||
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
|
|
||||||
y += 10;
|
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 ");
|
display.drawTextCentered(display.width() / 2, y, "[B] Browser ");
|
||||||
|
#else
|
||||||
|
y -= 10; // reclaim the row for standalone
|
||||||
#endif
|
#endif
|
||||||
y += 14;
|
y += 14;
|
||||||
|
|
||||||
@@ -917,6 +922,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
|||||||
#ifdef HAS_4G_MODEM
|
#ifdef HAS_4G_MODEM
|
||||||
sms_screen = new SMSScreen(this);
|
sms_screen = new SMSScreen(this);
|
||||||
#endif
|
#endif
|
||||||
|
map_screen = new MapScreen(this);
|
||||||
setCurrScreen(splash);
|
setCurrScreen(splash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1542,6 +1548,19 @@ void UITask::gotoWebReader() {
|
|||||||
}
|
}
|
||||||
#endif
|
#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) {
|
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||||
if (repeater_admin && isOnRepeaterAdmin()) {
|
if (repeater_admin && isOnRepeaterAdmin()) {
|
||||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
#include "WebReaderScreen.h"
|
#include "WebReaderScreen.h"
|
||||||
#endif
|
#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 {
|
class UITask : public AbstractUITask {
|
||||||
DisplayDriver* _display;
|
DisplayDriver* _display;
|
||||||
SensorManager* _sensors;
|
SensorManager* _sensors;
|
||||||
@@ -73,6 +76,7 @@ class UITask : public AbstractUITask {
|
|||||||
#ifdef MECK_WEB_READER
|
#ifdef MECK_WEB_READER
|
||||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||||
#endif
|
#endif
|
||||||
|
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
|
||||||
UIScreen* curr;
|
UIScreen* curr;
|
||||||
|
|
||||||
void userLedHandler();
|
void userLedHandler();
|
||||||
@@ -104,6 +108,7 @@ public:
|
|||||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||||
|
void gotoMapScreen(); // Navigate to map tile screen
|
||||||
#ifdef MECK_WEB_READER
|
#ifdef MECK_WEB_READER
|
||||||
void gotoWebReader(); // Navigate to web reader (browser)
|
void gotoWebReader(); // Navigate to web reader (browser)
|
||||||
#endif
|
#endif
|
||||||
@@ -131,6 +136,7 @@ public:
|
|||||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||||
|
bool isOnMapScreen() const { return curr == map_screen; }
|
||||||
#ifdef MECK_WEB_READER
|
#ifdef MECK_WEB_READER
|
||||||
bool isOnWebReader() const { return curr == web_reader; }
|
bool isOnWebReader() const { return curr == web_reader; }
|
||||||
#endif
|
#endif
|
||||||
@@ -174,6 +180,7 @@ public:
|
|||||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||||
|
UIScreen* getMapScreen() const { return map_screen; }
|
||||||
#ifdef MECK_WEB_READER
|
#ifdef MECK_WEB_READER
|
||||||
UIScreen* getWebReaderScreen() const { return web_reader; }
|
UIScreen* getWebReaderScreen() const { return web_reader; }
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -12,7 +12,31 @@
|
|||||||
#include <Fonts/FreeSans9pt7b.h>
|
#include <Fonts/FreeSans9pt7b.h>
|
||||||
#include <Fonts/FreeSansBold12pt7b.h>
|
#include <Fonts/FreeSansBold12pt7b.h>
|
||||||
#include <Fonts/FreeSans18pt7b.h>
|
#include <Fonts/FreeSans18pt7b.h>
|
||||||
#include <CRC32.h>
|
|
||||||
|
// 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<typename T> 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<typename T> 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"
|
#include "DisplayDriver.h"
|
||||||
|
|
||||||
@@ -34,7 +58,7 @@ class GxEPDDisplay : public DisplayDriver {
|
|||||||
bool _init = false;
|
bool _init = false;
|
||||||
bool _isOn = false;
|
bool _isOn = false;
|
||||||
uint16_t _curr_color;
|
uint16_t _curr_color;
|
||||||
CRC32 display_crc;
|
FrameCRC32 display_crc;
|
||||||
int last_display_crc_value = 0;
|
int last_display_crc_value = 0;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -60,4 +84,15 @@ public:
|
|||||||
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
|
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
|
||||||
uint16_t getTextWidth(const char* str) override;
|
uint16_t getTextWidth(const char* str) override;
|
||||||
void endFrame() 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; }
|
||||||
|
};
|
||||||
@@ -89,7 +89,7 @@ lib_deps =
|
|||||||
${sensor_base.lib_deps}
|
${sensor_base.lib_deps}
|
||||||
zinggjm/GxEPD2@^1.5.9
|
zinggjm/GxEPD2@^1.5.9
|
||||||
adafruit/Adafruit GFX Library@^1.11.0
|
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
|
; Meck unified builds — one codebase, three variants via build flags
|
||||||
@@ -157,7 +157,7 @@ build_flags =
|
|||||||
-D OFFLINE_QUEUE_SIZE=256
|
-D OFFLINE_QUEUE_SIZE=256
|
||||||
-D HAS_4G_MODEM=1
|
-D HAS_4G_MODEM=1
|
||||||
-D MECK_WEB_READER=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}
|
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||||
+<helpers/esp32/*.cpp>
|
+<helpers/esp32/*.cpp>
|
||||||
+<helpers/ui/MomentaryButton.cpp>
|
+<helpers/ui/MomentaryButton.cpp>
|
||||||
@@ -183,7 +183,7 @@ build_flags =
|
|||||||
-D OFFLINE_QUEUE_SIZE=256
|
-D OFFLINE_QUEUE_SIZE=256
|
||||||
-D HAS_4G_MODEM=1
|
-D HAS_4G_MODEM=1
|
||||||
-D MECK_WEB_READER=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}
|
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||||
+<helpers/esp32/*.cpp>
|
+<helpers/esp32/*.cpp>
|
||||||
+<helpers/ui/MomentaryButton.cpp>
|
+<helpers/ui/MomentaryButton.cpp>
|
||||||
|
|||||||
Reference in New Issue
Block a user