diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 77e06f5..507dbb5 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -205,6 +205,55 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); } +void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height, + const ImageRotation rotation, const bool invert) const { + // Calculate output dimensions based on rotation + const int outWidth = (rotation == ROTATE_90 || rotation == ROTATE_270) ? height : width; + const int outHeight = (rotation == ROTATE_90 || rotation == ROTATE_270) ? width : height; + + // Draw each pixel from the source bitmap with rotation + for (int srcY = 0; srcY < height; srcY++) { + for (int srcX = 0; srcX < width; srcX++) { + // Read source pixel (1-bit packed, MSB first) + const int byteIndex = srcY * ((width + 7) / 8) + (srcX / 8); + const int bitIndex = 7 - (srcX % 8); + const bool srcPixel = (bitmap[byteIndex] >> bitIndex) & 1; + + // Apply inversion if requested + const bool pixelState = invert ? !srcPixel : srcPixel; + + // Skip black pixels (transparent on black background) + if (!pixelState) continue; + + // Calculate destination coordinates based on rotation (clockwise) + int dstX, dstY; + switch (rotation) { + case ROTATE_0: + dstX = srcX; + dstY = srcY; + break; + case ROTATE_90: // 90° clockwise + dstX = height - 1 - srcY; + dstY = srcX; + break; + case ROTATE_180: + dstX = width - 1 - srcX; + dstY = height - 1 - srcY; + break; + case ROTATE_270: // 270° clockwise (= 90° counter-clockwise) + dstX = srcY; + dstY = width - 1 - srcX; + break; + } + + // Draw the pixel at the rotated position + // Note: pixelState=true means white pixel, but drawPixel(state=true) draws black + // So we invert: draw with state=false for white pixels + drawPixel(x + dstX, y + dstY, false); + } + } +} + void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, const float cropX, const float cropY, const bool invert) const { // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 722b076..6d3a579 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -11,6 +11,9 @@ class GfxRenderer { public: enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; + // Image rotation (clockwise) + enum ImageRotation { ROTATE_0 = 0, ROTATE_90 = 90, ROTATE_180 = 180, ROTATE_270 = 270 }; + // Logical screen orientation from the perspective of callers enum Orientation { Portrait, // 480x800 logical coordinates (current default) @@ -69,6 +72,8 @@ class GfxRenderer { // Handles current render mode (BW, GRAYSCALE_MSB, GRAYSCALE_LSB) void fillRectGray(int x, int y, int width, int height, uint8_t grayLevel) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; + void drawImageRotated(const uint8_t bitmap[], int x, int y, int width, int height, + ImageRotation rotation, bool invert = false) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0, bool invert = false) const; void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, bool invert = false) const; diff --git a/platformio.ini b/platformio.ini index 619f5cd..bad506d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -44,6 +44,8 @@ board_build.partitions = partitions.csv extra_scripts = pre:scripts/build_html.py + pre:scripts/version_hash.py + pre:scripts/pre_flash.py ; Libraries lib_deps = diff --git a/scripts/pre_flash.py b/scripts/pre_flash.py new file mode 100644 index 0000000..910d499 --- /dev/null +++ b/scripts/pre_flash.py @@ -0,0 +1,47 @@ +""" +Pre-upload script that sends FLASH command to device before firmware upload. + +This allows the firmware to display "Flashing firmware..." on the e-ink display +before the actual flash begins. The e-ink retains this message throughout the +flash process since it doesn't require power to maintain the display. + +Protocol: Sends "FLASH:version\n" where version is read from platformio.ini +""" + +Import("env") +import serial +import time +from version_utils import get_version + + +def before_upload(source, target, env): + """Send FLASH command with version to device before upload begins.""" + port = env.GetProjectOption("upload_port", None) + + if not port: + import serial.tools.list_ports + + # Look for common ESP32-C3 USB serial port patterns + ports = [ + p.device + for p in serial.tools.list_ports.comports() + if "usbmodem" in p.device.lower() or "ttyacm" in p.device.lower() + ] + port = ports[0] if ports else None + + if port: + try: + version = get_version(env) + ser = serial.Serial(port, 115200, timeout=1) + ser.write(f"FLASH:{version}\n".encode()) + ser.flush() + ser.close() + time.sleep(0.8) # Wait for e-ink fast refresh (~500ms) plus margin + print(f"[pre_flash] Flash notification sent to {port} (version {version})") + except Exception as e: + print(f"[pre_flash] Notification skipped: {e}") + else: + print("[pre_flash] No serial port found, skipping notification") + + +env.AddPreAction("upload", before_upload) diff --git a/scripts/version_hash.py b/scripts/version_hash.py new file mode 100644 index 0000000..09f6fe1 --- /dev/null +++ b/scripts/version_hash.py @@ -0,0 +1,26 @@ +""" +Build script that adds git hash to CROSSPOINT_VERSION for dev builds. + +This script runs during the build process and modifies the CROSSPOINT_VERSION +define to include the short git commit hash, e.g., "0.15.1-dev@7349fbb" +""" + +Import("env") +from version_utils import get_git_short_hash + + +# Only modify version for non-release builds +pio_env = env.get("PIOENV", "default") +if pio_env != "gh_release": + git_hash = get_git_short_hash() + if git_hash: + # Get current build flags and find/replace CROSSPOINT_VERSION + build_flags = env.get("BUILD_FLAGS", []) + new_flags = [] + for flag in build_flags: + if "CROSSPOINT_VERSION" in flag and "-dev" in flag: + # Replace -dev with -dev@hash + flag = flag.replace("-dev", f"-dev@{git_hash}") + new_flags.append(flag) + env.Replace(BUILD_FLAGS=new_flags) + print(f"[version_hash] Added git hash to version: @{git_hash}") diff --git a/scripts/version_utils.py b/scripts/version_utils.py new file mode 100644 index 0000000..c822602 --- /dev/null +++ b/scripts/version_utils.py @@ -0,0 +1,47 @@ +""" +Shared version utilities for PlatformIO build scripts. + +Provides functions to get version info including git hash for dev builds. +""" + +import configparser +import subprocess + + +def get_git_short_hash(): + """Get the short hash of the current HEAD commit.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except Exception: + return None + + +def get_version(env): + """ + Get version from platformio.ini, adding -dev@hash suffix for dev builds. + + Args: + env: PlatformIO environment object + + Returns: + Version string, e.g., "0.15.1" or "0.15.1-dev@7349fbb" + """ + config = configparser.ConfigParser() + config.read("platformio.ini") + version = config.get("crosspoint", "version", fallback="unknown") + + # Add -dev@hash suffix for non-release environments (matches platformio.ini build_flags) + pio_env = env.get("PIOENV", "default") + if pio_env != "gh_release": + version += "-dev" + git_hash = get_git_short_hash() + if git_hash: + version += f"@{git_hash}" + + return version diff --git a/src/images/LockIcon.h b/src/images/LockIcon.h new file mode 100644 index 0000000..32d5f2d --- /dev/null +++ b/src/images/LockIcon.h @@ -0,0 +1,65 @@ +#pragma once +#include + +// Lock icon dimensions (original orientation - shackle on top) +static constexpr int LOCK_ICON_WIDTH = 32; +static constexpr int LOCK_ICON_HEIGHT = 40; + +// 32x40 pixel padlock icon (1-bit bitmap, MSB first) +// 0 = black pixel, 1 = white pixel +// Original orientation: shackle on top, body below +// Use drawImageRotated() to rotate as needed for different screen orientations +static const uint8_t LockIcon[] = { + // Row 0-1: Empty space above shackle + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + // Row 2-3: Shackle top curve + 0x00, 0x0F, 0xF0, 0x00, // ....####.... + 0x00, 0x3F, 0xFC, 0x00, // ..########.. + // Row 4-5: Shackle upper sides + 0x00, 0x78, 0x1E, 0x00, // .####..####. + 0x00, 0xE0, 0x07, 0x00, // ###......### + // Row 6-9: Extended shackle legs (longer for better visual) + 0x00, 0xC0, 0x03, 0x00, // ##........## + 0x01, 0xC0, 0x03, 0x80, // ###......### + 0x01, 0x80, 0x01, 0x80, // ##........## + 0x01, 0x80, 0x01, 0x80, // ##........## + // Row 10-13: Shackle legs continue into body + 0x01, 0x80, 0x01, 0x80, // ##........## + 0x01, 0x80, 0x01, 0x80, // ##........## + 0x01, 0x80, 0x01, 0x80, // ##........## + 0x01, 0x80, 0x01, 0x80, // ##........## + // Row 14-15: Body top + 0x0F, 0xFF, 0xFF, 0xF0, // ############ + 0x1F, 0xFF, 0xFF, 0xF8, // ############## + // Row 16-17: Body top edge + 0x3F, 0xFF, 0xFF, 0xFC, // ################ + 0x3F, 0xFF, 0xFF, 0xFC, // ################ + // Row 18-29: Solid body (no keyhole) + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + // Row 30-33: Body lower section + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xFC, + // Row 34-35: Body bottom edge + 0x3F, 0xFF, 0xFF, 0xFC, + 0x1F, 0xFF, 0xFF, 0xF8, + // Row 36-37: Body bottom + 0x0F, 0xFF, 0xFF, 0xF0, + 0x00, 0x00, 0x00, 0x00, + // Row 38-39: Empty space below + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, +}; diff --git a/src/main.cpp b/src/main.cpp index c7acb18..8a23725 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,7 @@ #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" #include "fontIds.h" +#include "images/LockIcon.h" #define SPI_FQ 40000000 // Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) @@ -128,6 +129,118 @@ void logMemoryState(const char* tag, const char* context) { #define logMemoryState(tag, context) ((void)0) #endif +// Flash command detection - receives "FLASH\n" from pre_flash.py script +static String flashCmdBuffer; + +void checkForFlashCommand() { + while (Serial.available()) { + char c = Serial.read(); + if (c == '\n') { + if (flashCmdBuffer.startsWith("FLASH")) { + // Extract version if provided (format: "FLASH:x.y.z") + String newVersion = ""; + if (flashCmdBuffer.length() > 6 && flashCmdBuffer.charAt(5) == ':') { + newVersion = flashCmdBuffer.substring(6); + } + + Serial.printf("[%lu] [FLS] Flash command received (version: %s), displaying message\n", millis(), + newVersion.length() > 0 ? newVersion.c_str() : "unknown"); + + // Display flash message with inverted colors (black background, white text) + renderer.clearScreen(0x00); // Black background + renderer.drawCenteredText(NOTOSANS_14_FONT_ID, 200, "Flashing firmware...", false, EpdFontFamily::BOLD); + + // Show new version under title if provided + if (newVersion.length() > 0) { + String versionText = "v" + newVersion; + renderer.drawCenteredText(UI_10_FONT_ID, 260, versionText.c_str(), false); + } + + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Do not disconnect USB", false); + + // Get screen dimensions and orientation for positioning + const int screenW = renderer.getScreenWidth(); + const int screenH = renderer.getScreenHeight(); + + // Show current version in bottom-left corner (orientation-aware) + // "Bottom-left" is relative to the current orientation + constexpr int versionMargin = 10; + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION); + int versionX, versionY; + switch (renderer.getOrientation()) { + case GfxRenderer::Portrait: // Bottom-left is actual bottom-left + versionX = versionMargin; + versionY = screenH - 30; + break; + case GfxRenderer::PortraitInverted: // Bottom-left is actual top-right + versionX = screenW - textWidth - versionMargin; + versionY = 20; + break; + case GfxRenderer::LandscapeClockwise: // Bottom-left is actual bottom-right + versionX = screenW - textWidth - versionMargin; + versionY = screenH - 30; + break; + case GfxRenderer::LandscapeCounterClockwise: // Bottom-left is actual bottom-left + versionX = versionMargin; + versionY = screenH - 30; + break; + } + renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false); + + // Position and rotate lock icon based on current orientation (USB port location) + // USB port locations: Portrait=bottom-left, PortraitInverted=top-right, + // LandscapeCW=top-left, LandscapeCCW=bottom-right + // Position offsets: edge margin + half-width offset to center on USB port + constexpr int edgeMargin = 28; // Distance from screen edge + constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering + int iconX, iconY; + GfxRenderer::ImageRotation rotation; + int outW, outH; // Note: 90/270 rotation swaps output dimensions + switch (renderer.getOrientation()) { + case GfxRenderer::Portrait: // USB at bottom-left, shackle points right + rotation = GfxRenderer::ROTATE_90; + outW = LOCK_ICON_HEIGHT; + outH = LOCK_ICON_WIDTH; + iconX = edgeMargin; + iconY = screenH - outH - edgeMargin - halfWidth; + break; + case GfxRenderer::PortraitInverted: // USB at top-right, shackle points left + rotation = GfxRenderer::ROTATE_270; + outW = LOCK_ICON_HEIGHT; + outH = LOCK_ICON_WIDTH; + iconX = screenW - outW - edgeMargin; + iconY = edgeMargin + halfWidth; + break; + case GfxRenderer::LandscapeClockwise: // USB at top-left, shackle points down + rotation = GfxRenderer::ROTATE_180; + outW = LOCK_ICON_WIDTH; + outH = LOCK_ICON_HEIGHT; + iconX = edgeMargin + halfWidth; + iconY = edgeMargin; + break; + case GfxRenderer::LandscapeCounterClockwise: // USB at bottom-right, shackle points up + rotation = GfxRenderer::ROTATE_0; + outW = LOCK_ICON_WIDTH; + outH = LOCK_ICON_HEIGHT; + iconX = screenW - outW - edgeMargin - halfWidth; + iconY = screenH - outH - edgeMargin; + break; + } + renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation); + + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + flashCmdBuffer = ""; + } else if (c != '\r') { + flashCmdBuffer += c; + // Prevent buffer overflow from random serial data (increased for version info) + if (flashCmdBuffer.length() > 30) { + flashCmdBuffer = ""; + } + } + } +} + void exitActivity() { if (currentActivity) { logMemoryState("MAIN", "Before onExit"); @@ -391,6 +504,9 @@ void setup() { } void loop() { + // Check for flash command from pre_flash.py script (must be first to catch before upload) + checkForFlashCommand(); + static unsigned long maxLoopDuration = 0; const unsigned long loopStartTime = millis(); static unsigned long lastMemPrint = 0;