add proper firmware flashing screen

This commit is contained in:
cottongin 2026-01-27 13:16:20 -05:00
parent 7349fbb208
commit a04388fd6c
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
8 changed files with 357 additions and 0 deletions

View File

@ -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)

View File

@ -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;

View File

@ -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 =

47
scripts/pre_flash.py Normal file
View File

@ -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)

26
scripts/version_hash.py Normal file
View File

@ -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}")

47
scripts/version_utils.py Normal file
View File

@ -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

65
src/images/LockIcon.h Normal file
View File

@ -0,0 +1,65 @@
#pragma once
#include <cstdint>
// 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,
};

View File

@ -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;