This commit is contained in:
Xuan-Son Nguyen 2026-01-27 21:26:21 +11:00 committed by GitHub
commit 7c184bdcd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2009 additions and 84 deletions

12
docs/emulation.md Normal file
View File

@ -0,0 +1,12 @@
# Emulation (development)
TODO: write this
Build the program using `env:emulation`, this is required to re-route `Serial.print` to qemu's stdout.
To run it:
```sh
cd scripts/emulation
docker compose up
```

View File

@ -3,7 +3,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <JpegToBmpConverter.h> #include <JpegToBmpConverter.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <ZipFile.h> #include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContainerParser.h"

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SDCardManager.h> #include <HalStorage.h>
#include <string> #include <string>

View File

@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <utility> #include <utility>
#include <vector> #include <vector>

View File

@ -1,6 +1,6 @@
#include "Section.h" #include "Section.h"
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
#include "Page.h" #include "Page.h"

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <SdFat.h> #include <HalStorage.h>
#include <list> #include <list>
#include <memory> #include <memory>

View File

@ -2,7 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <expat.h> #include <expat.h>
#include "../Page.h" #include "../Page.h"

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <cstdint> #include <cstdint>

View File

@ -10,19 +10,19 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
// Logical portrait (480x800) → panel (800x480) // Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise // Rotation: 90 degrees clockwise
*rotatedX = y; *rotatedX = y;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x;
break; break;
} }
case LandscapeClockwise: { case LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x; *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y; *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y;
break; break;
} }
case PortraitInverted: { case PortraitInverted: {
// Logical portrait (480x800) → panel (800x480) // Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise // Rotation: 90 degrees counter-clockwise
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y; *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y;
*rotatedY = x; *rotatedY = x;
break; break;
} }
@ -49,14 +49,14 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
rotateCoordinates(x, y, &rotatedX, &rotatedY); rotateCoordinates(x, y, &rotatedX, &rotatedY);
// Bounds checking against physical panel dimensions // Bounds checking against physical panel dimensions
if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 || if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 ||
rotatedY >= EInkDisplay::DISPLAY_HEIGHT) { rotatedY >= HalDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY); Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
return; return;
} }
// Calculate byte position and bit position // Calculate byte position and bit position
const uint16_t byteIndex = rotatedY * EInkDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8); const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8);
const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first
if (state) { if (state) {
@ -392,12 +392,12 @@ void GfxRenderer::invertScreen() const {
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis()); Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
return; return;
} }
for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) { for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) {
buffer[i] = ~buffer[i]; buffer[i] = ~buffer[i];
} }
} }
void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
einkDisplay.displayBuffer(refreshMode); einkDisplay.displayBuffer(refreshMode);
} }
@ -418,13 +418,13 @@ int GfxRenderer::getScreenWidth() const {
case Portrait: case Portrait:
case PortraitInverted: case PortraitInverted:
// 480px wide in portrait logical coordinates // 480px wide in portrait logical coordinates
return EInkDisplay::DISPLAY_HEIGHT; return HalDisplay::DISPLAY_HEIGHT;
case LandscapeClockwise: case LandscapeClockwise:
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
// 800px wide in landscape logical coordinates // 800px wide in landscape logical coordinates
return EInkDisplay::DISPLAY_WIDTH; return HalDisplay::DISPLAY_WIDTH;
} }
return EInkDisplay::DISPLAY_HEIGHT; return HalDisplay::DISPLAY_HEIGHT;
} }
int GfxRenderer::getScreenHeight() const { int GfxRenderer::getScreenHeight() const {
@ -432,13 +432,13 @@ int GfxRenderer::getScreenHeight() const {
case Portrait: case Portrait:
case PortraitInverted: case PortraitInverted:
// 800px tall in portrait logical coordinates // 800px tall in portrait logical coordinates
return EInkDisplay::DISPLAY_WIDTH; return HalDisplay::DISPLAY_WIDTH;
case LandscapeClockwise: case LandscapeClockwise:
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
// 480px tall in landscape logical coordinates // 480px tall in landscape logical coordinates
return EInkDisplay::DISPLAY_HEIGHT; return HalDisplay::DISPLAY_HEIGHT;
} }
return EInkDisplay::DISPLAY_WIDTH; return HalDisplay::DISPLAY_WIDTH;
} }
int GfxRenderer::getSpaceWidth(const int fontId) const { int GfxRenderer::getSpaceWidth(const int fontId) const {
@ -640,9 +640,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); }
void GfxRenderer::copyGrayscaleLsbBuffers() const { einkDisplay.copyGrayscaleLsbBuffers(einkDisplay.getFrameBuffer()); } void GfxRenderer::copyGrayscaleLsbBuffers() const { einkDisplay.copyGrayscaleLsbBuffers(einkDisplay.getFrameBuffer()); }

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <EInkDisplay.h> #include <HalDisplay.h>
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <map> #include <map>
@ -21,11 +21,11 @@ class GfxRenderer {
private: private:
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE, static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE,
"BW buffer chunking does not line up with display buffer size"); "BW buffer chunking does not line up with display buffer size");
EInkDisplay& einkDisplay; HalDisplay& einkDisplay;
RenderMode renderMode; RenderMode renderMode;
Orientation orientation; Orientation orientation;
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
@ -36,7 +36,7 @@ class GfxRenderer {
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
public: public:
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {} explicit GfxRenderer(HalDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
~GfxRenderer() { freeBwBufferChunks(); } ~GfxRenderer() { freeBwBufferChunks(); }
static constexpr int VIEWABLE_MARGIN_TOP = 9; static constexpr int VIEWABLE_MARGIN_TOP = 9;
@ -54,7 +54,7 @@ class GfxRenderer {
// Screen ops // Screen ops
int getScreenWidth() const; int getScreenWidth() const;
int getScreenHeight() const; int getScreenHeight() const;
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region // EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(int x, int y, int width, int height) const; void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const; void invertScreen() const;
@ -106,6 +106,6 @@ class GfxRenderer {
// Low level functions // Low level functions
uint8_t* getFrameBuffer() const; uint8_t* getFrameBuffer() const;
static size_t getBufferSize(); static size_t getBufferSize();
void grayscaleRevert() const; // void grayscaleRevert() const; // unused
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const; void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
}; };

View File

@ -1,7 +1,7 @@
#include "JpegToBmpConverter.h" #include "JpegToBmpConverter.h"
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SdFat.h> #include <HalStorage.h>
#include <picojpeg.h> #include <picojpeg.h>
#include <cstdio> #include <cstdio>

View File

@ -2,7 +2,7 @@
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <MD5Builder.h> #include <MD5Builder.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
// Initialize the static instance // Initialize the static instance

View File

@ -2,7 +2,7 @@
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <MD5Builder.h> #include <MD5Builder.h>
#include <SDCardManager.h> #include <HalStorage.h>
namespace { namespace {
// Extract filename from path (everything after last '/') // Extract filename from path (everything after last '/')

View File

@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <iostream> #include <iostream>

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SDCardManager.h> #include <HalStorage.h>
#include <memory> #include <memory>
#include <string> #include <string>

View File

@ -9,7 +9,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
bool Xtc::load() { bool Xtc::load() {
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str()); Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str());

View File

@ -9,7 +9,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <cstring> #include <cstring>

View File

@ -7,7 +7,7 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <functional> #include <functional>
#include <memory> #include <memory>

View File

@ -1,7 +1,7 @@
#include "ZipFile.h" #include "ZipFile.h"
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <miniz.h> #include <miniz.h>
bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t* outputBuf, const size_t inflatedSize) { bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t* outputBuf, const size_t inflatedSize) {

View File

@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>

186
lib/hal/EmulationUtils.h Normal file
View File

@ -0,0 +1,186 @@
#pragma once
#include <Arduino.h>
#include <vector>
namespace EmulationUtils {
// Packet format (from device to host):
// $$CMD_(COMMAND):[(ARG0)][:(ARG1)][:(ARG2)]$$\n
// Host response with either a base64-encoded payload or int64, terminated by newline:
// (BASE64_ENCODED_PAYLOAD)\n
// 123\n
static const char* CMD_DISPLAY = "DISPLAY"; // arg0: base64-encoded buffer -- no return value
static const char* CMD_FS_LIST = "FS_LIST"; // arg0: path, arg1: max files -- returns list of filenames, one per line, terminated by empty line
static const char* CMD_FS_READ = "FS_READ"; // arg0: path, arg1: offset, arg2: length (-1 means read all) -- returns base64-encoded file contents
static const char* CMD_FS_STAT = "FS_STAT"; // arg0: path -- return file size int64_t; -1 means not found; -2 means is a directory
static const char* CMD_FS_WRITE = "FS_WRITE"; // arg0: path, arg1: base64-encoded data, arg2: offset, arg3: is inplace (0/1) -- return int64_t bytes written
static const char* CMD_FS_MKDIR = "FS_MKDIR"; // arg0: path -- return int64_t: 0=success
static const char* CMD_FS_RM = "FS_RM"; // arg0: path -- return int64_t: 0=success
static const char* CMD_BUTTON = "BUTTON"; // arg0: action ("read") -- return int64_t: HalInput state bitmask
static std::string base64_encode(const char* buf, unsigned int bufLen);
static std::vector<uint8_t> base64_decode(const char* encoded_string, unsigned int in_len);
static SemaphoreHandle_t sendMutex = xSemaphoreCreateMutex();
static void sendCmd(const char* cmd, const char* arg0 = nullptr, const char* arg1 = nullptr, const char* arg2 = nullptr, const char* arg3 = nullptr) {
xSemaphoreTake(sendMutex, portMAX_DELAY);
if (cmd != CMD_BUTTON) {
Serial.printf("[%lu] [EMU] Sending command: %s\n", millis(), cmd);
}
Serial.print("$$CMD_");
Serial.print(cmd);
if (arg0 != nullptr) {
Serial.print(":");
Serial.print(arg0);
} else {
Serial.print(":"); // ensure at least one colon
}
if (arg1 != nullptr) {
Serial.print(":");
Serial.print(arg1);
}
if (arg2 != nullptr) {
Serial.print(":");
Serial.print(arg2);
}
if (arg3 != nullptr) {
Serial.print(":");
Serial.print(arg3);
}
Serial.print("$$\n");
xSemaphoreGive(sendMutex);
}
static String recvRespStr() {
unsigned long startMillis = millis();
String line;
const unsigned long timeoutMs = 5000;
while (millis() - startMillis < (unsigned long)timeoutMs) {
if (Serial.available()) {
char c = Serial.read();
if (c == '\n') {
return line;
} else {
line += c;
}
}
}
// should never reach here
Serial.println("FATAL: Timeout waiting for response");
return String();
}
static std::vector<uint8_t> recvRespBuf() {
String respStr = recvRespStr();
return base64_decode(respStr.c_str(), respStr.length());
}
static int64_t recvRespInt64() {
String respStr = recvRespStr();
return respStr.toInt();
}
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
static inline bool is_base64(char c) {
return (isalnum(c) || (c == '+') || (c == '/'));
}
static std::string base64_encode(const char* buf, unsigned int bufLen) {
std::string ret;
ret.reserve(bufLen * 4 / 3 + 4); // reserve enough space
int i = 0;
int j = 0;
char char_array_3[3];
char char_array_4[4];
while (bufLen--) {
char_array_3[i++] = *(buf++);
if (i == 3) {
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for(i = 0; (i < 4) ; i++) {
ret += base64_chars[char_array_4[i]];
}
i = 0;
}
}
if (i) {
for(j = i; j < 3; j++) {
char_array_3[j] = '\0';
}
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for (j = 0; (j < i + 1); j++) {
ret += base64_chars[char_array_4[j]];
}
while((i++ < 3)) {
ret += '=';
}
}
return ret;
}
static std::vector<uint8_t> base64_decode(const char* encoded_string, unsigned int in_len) {
int i = 0;
int j = 0;
int in_ = 0;
char char_array_4[4], char_array_3[3];
std::vector<uint8_t> ret;
while (in_len-- && ( encoded_string[in_] != '=') && is_base64(encoded_string[in_])) {
char_array_4[i++] = encoded_string[in_]; in_++;
if (i ==4) {
for (i = 0; i < 4; i++) {
char_array_4[i] = base64_chars.find(char_array_4[i]);
}
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; (i < 3); i++) {
ret.push_back(char_array_3[i]);
}
i = 0;
}
}
if (i) {
for (j = i; j < 4; j++) {
char_array_4[j] = 0;
}
for (j = 0; j < 4; j++) {
char_array_4[j] = base64_chars.find(char_array_4[j]);
}
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (j = 0; (j < i - 1); j++) {
ret.push_back(char_array_3[j]);
}
}
return ret;
}
} // namespace EmulationUtils

155
lib/hal/HalDisplay.cpp Normal file
View File

@ -0,0 +1,155 @@
#include <HalDisplay.h>
#include <EmulationUtils.h>
#include <string>
HalDisplay::HalDisplay(int8_t sclk, int8_t mosi, int8_t cs, int8_t dc, int8_t rst, int8_t busy) : einkDisplay(sclk, mosi, cs, dc, rst, busy) {
if (is_emulated) {
emuFramebuffer0 = new uint8_t[BUFFER_SIZE];
}
}
HalDisplay::~HalDisplay() {
if (emuFramebuffer0) {
delete[] emuFramebuffer0;
emuFramebuffer0 = nullptr;
}
}
void HalDisplay::begin() {
if (!is_emulated) {
einkDisplay.begin();
} else {
Serial.printf("[%lu] [ ] Emulated display initialized\n", millis());
// no-op
}
}
void HalDisplay::clearScreen(uint8_t color) const {
if (!is_emulated) {
einkDisplay.clearScreen(color);
} else {
Serial.printf("[%lu] [ ] Emulated clear screen with color 0x%02X\n", millis(), color);
memset(emuFramebuffer0, color, BUFFER_SIZE);
}
}
void HalDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, bool fromProgmem) const {
if (!is_emulated) {
einkDisplay.drawImage(imageData, x, y, w, h, fromProgmem);
} else {
Serial.printf("[%lu] [ ] Emulated draw image at (%u, %u) with size %ux%u\n", millis(), x, y, w, h);
// Calculate bytes per line for the image
const uint16_t imageWidthBytes = w / 8;
// Copy image data to frame buffer
for (uint16_t row = 0; row < h; row++) {
const uint16_t destY = y + row;
if (destY >= DISPLAY_HEIGHT)
break;
const uint16_t destOffset = destY * DISPLAY_WIDTH_BYTES + (x / 8);
const uint16_t srcOffset = row * imageWidthBytes;
for (uint16_t col = 0; col < imageWidthBytes; col++) {
if ((x / 8 + col) >= DISPLAY_WIDTH_BYTES)
break;
if (fromProgmem) {
emuFramebuffer0[destOffset + col] = pgm_read_byte(&imageData[srcOffset + col]);
} else {
emuFramebuffer0[destOffset + col] = imageData[srcOffset + col];
}
}
}
}
}
void HalDisplay::displayBuffer(RefreshMode mode) {
if (!is_emulated) {
einkDisplay.displayBuffer(mode);
} else {
Serial.printf("[%lu] [ ] Emulated display buffer with mode %d\n", millis(), static_cast<int>(mode));
std::string b64 = EmulationUtils::base64_encode(reinterpret_cast<char*>(emuFramebuffer0), BUFFER_SIZE);
EmulationUtils::sendCmd(EmulationUtils::CMD_DISPLAY, b64.c_str());
// no response expected
}
}
void HalDisplay::refreshDisplay(RefreshMode mode, bool turnOffScreen) {
if (!is_emulated) {
einkDisplay.refreshDisplay(mode, turnOffScreen);
} else {
Serial.printf("[%lu] [ ] Emulated refresh display with mode %d, turnOffScreen %d\n", millis(), static_cast<int>(mode), turnOffScreen);
// emulated delay
if (mode == RefreshMode::FAST_REFRESH) {
delay(500);
} else if (mode == RefreshMode::HALF_REFRESH) {
delay(1000);
} else if (mode == RefreshMode::FULL_REFRESH) {
delay(2000);
}
}
}
void HalDisplay::deepSleep() {
if (!is_emulated) {
einkDisplay.deepSleep();
} else {
Serial.printf("[%lu] [ ] Emulated deep sleep\n", millis());
// no-op
}
}
uint8_t* HalDisplay::getFrameBuffer() const {
if (!is_emulated) {
return einkDisplay.getFrameBuffer();
} else {
return emuFramebuffer0;
}
}
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
if (!is_emulated) {
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
} else {
Serial.printf("[%lu] [ ] Emulated copy grayscale buffers\n", millis());
// TODO: not sure what this does
}
}
void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) {
if (!is_emulated) {
einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer);
} else {
Serial.printf("[%lu] [ ] Emulated copy grayscale LSB buffers\n", millis());
// TODO: not sure what this does
}
}
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) {
if (!is_emulated) {
einkDisplay.copyGrayscaleMsbBuffers(msbBuffer);
} else {
Serial.printf("[%lu] [ ] Emulated copy grayscale MSB buffers\n", millis());
// TODO: not sure what this does
}
}
void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) {
if (!is_emulated) {
einkDisplay.cleanupGrayscaleBuffers(bwBuffer);
} else {
Serial.printf("[%lu] [ ] Emulated cleanup grayscale buffers\n", millis());
// TODO: not sure what this does
}
}
void HalDisplay::displayGrayBuffer() {
if (!is_emulated) {
einkDisplay.displayGrayBuffer();
} else {
Serial.printf("[%lu] [ ] Emulated display gray buffer\n", millis());
// TODO: not sure what this does
}
}

57
lib/hal/HalDisplay.h Normal file
View File

@ -0,0 +1,57 @@
#pragma once
#include <EInkDisplay.h>
#include <Arduino.h>
#include <SPI.h>
class HalDisplay {
public:
// Constructor with pin configuration
HalDisplay(int8_t sclk, int8_t mosi, int8_t cs, int8_t dc, int8_t rst, int8_t busy);
// Destructor
~HalDisplay();
// Refresh modes (guarded to avoid redefinition in test builds)
using RefreshMode = EInkDisplay::RefreshMode;
static constexpr EInkDisplay::RefreshMode FULL_REFRESH = EInkDisplay::FULL_REFRESH;
static constexpr EInkDisplay::RefreshMode HALF_REFRESH = EInkDisplay::HALF_REFRESH;
static constexpr EInkDisplay::RefreshMode FAST_REFRESH = EInkDisplay::FAST_REFRESH;
// Initialize the display hardware and driver
void begin();
// Display dimensions
static constexpr uint16_t DISPLAY_WIDTH = 800;
static constexpr uint16_t DISPLAY_HEIGHT = 480;
static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8;
static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT;
// Frame buffer operations
void clearScreen(uint8_t color = 0xFF) const;
void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, bool fromProgmem = false) const;
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH);
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
// Power management
void deepSleep();
// Access to frame buffer
uint8_t* getFrameBuffer() const;
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
void cleanupGrayscaleBuffers(const uint8_t* bwBuffer);
void displayGrayBuffer();
private:
bool is_emulated = CROSSPOINT_EMULATED;
// real display implementation
EInkDisplay einkDisplay;
// emulated display implementation
uint8_t* emuFramebuffer0 = nullptr;
};

122
lib/hal/HalInput.cpp Normal file
View File

@ -0,0 +1,122 @@
#include "HalInput.h"
#include "EmulationUtils.h"
#include <esp_sleep.h>
void HalInput::begin() {
#if CROSSPOINT_EMULATED == 0
inputMgr.begin();
#endif
}
void HalInput::update() {
#if CROSSPOINT_EMULATED == 0
inputMgr.update();
#else
const unsigned long currentTime = millis();
EmulationUtils::sendCmd(EmulationUtils::CMD_BUTTON, "read");
auto res = EmulationUtils::recvRespInt64();
assert(res >= 0);
const uint8_t state = static_cast<uint8_t>(res);
// Always clear events first
pressedEvents = 0;
releasedEvents = 0;
// Debounce
if (state != lastState) {
lastDebounceTime = currentTime;
lastState = state;
}
static constexpr unsigned long DEBOUNCE_DELAY = 5;
if ((currentTime - lastDebounceTime) > DEBOUNCE_DELAY) {
if (state != currentState) {
// Calculate pressed and released events
pressedEvents = state & ~currentState;
releasedEvents = currentState & ~state;
// If pressing buttons and wasn't before, start recording time
if (pressedEvents > 0 && currentState == 0) {
buttonPressStart = currentTime;
}
// If releasing a button and no other buttons being pressed, record finish time
if (releasedEvents > 0 && state == 0) {
buttonPressFinish = currentTime;
}
currentState = state;
}
}
#endif
}
bool HalInput::isPressed(uint8_t buttonIndex) const {
#if CROSSPOINT_EMULATED == 0
return inputMgr.isPressed(buttonIndex);
#else
return currentState & (1 << buttonIndex);
#endif
}
bool HalInput::wasPressed(uint8_t buttonIndex) const {
#if CROSSPOINT_EMULATED == 0
return inputMgr.wasPressed(buttonIndex);
#else
return currentState & (1 << buttonIndex);
#endif
}
bool HalInput::wasAnyPressed() const {
#if CROSSPOINT_EMULATED == 0
return inputMgr.wasAnyPressed();
#else
return pressedEvents > 0;
#endif
}
bool HalInput::wasReleased(uint8_t buttonIndex) const {
#if CROSSPOINT_EMULATED == 0
return inputMgr.wasReleased(buttonIndex);
#else
return releasedEvents & (1 << buttonIndex);
#endif
}
bool HalInput::wasAnyReleased() const {
#if CROSSPOINT_EMULATED == 0
return inputMgr.wasAnyReleased();
#else
return releasedEvents > 0;
#endif
}
unsigned long HalInput::getHeldTime() const {
#if CROSSPOINT_EMULATED == 0
return inputMgr.getHeldTime();
#else
// Still hold a button
if (currentState > 0) {
return millis() - buttonPressStart;
}
return buttonPressFinish - buttonPressStart;
#endif
}
void startDeepSleep(InputManager& inputMgr) {
#if CROSSPOINT_EMULATED == 0
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(InputManager::BTN_POWER)) {
delay(50);
inputMgr.update();
}
// Enter Deep Sleep
esp_deep_sleep_start();
#else
Serial.println("[ ] GPIO wakeup setup skipped in emulation mode.");
#endif
}

52
lib/hal/HalInput.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
#include <Arduino.h>
#if CROSSPOINT_EMULATED == 0
#include <InputManager.h>
#endif
class HalInput {
#if CROSSPOINT_EMULATED == 0
InputManager inputMgr;
#endif
public:
HalInput() = default;
void begin();
void update();
bool isPressed(uint8_t buttonIndex) const;
bool wasPressed(uint8_t buttonIndex) const;
bool wasAnyPressed() const;
bool wasReleased(uint8_t buttonIndex) const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
// Button indices
static constexpr uint8_t BTN_BACK = 0;
static constexpr uint8_t BTN_CONFIRM = 1;
static constexpr uint8_t BTN_LEFT = 2;
static constexpr uint8_t BTN_RIGHT = 3;
static constexpr uint8_t BTN_UP = 4;
static constexpr uint8_t BTN_DOWN = 5;
static constexpr uint8_t BTN_POWER = 6;
private:
// emulation state
uint8_t currentState;
uint8_t lastState;
uint8_t pressedEvents;
uint8_t releasedEvents;
unsigned long lastDebounceTime;
unsigned long buttonPressStart;
unsigned long buttonPressFinish;
};
// TODO @ngxson : this is a trick to avoid changing too many files at once.
// consider refactoring in a dedicated PR later.
#if CROSSPOINT_EMULATED == 1
using InputManager = HalInput;
#endif
void startDeepSleep(InputManager& inputMgr);

327
lib/hal/HalStorage.cpp Normal file
View File

@ -0,0 +1,327 @@
#include "HalStorage.h"
#include "EmulationUtils.h"
#include <vector>
#if CROSSPOINT_EMULATED == 0
#include <SDCardManager.h>
#endif
HalStorage HalStorage::instance;
HalStorage::HalStorage() {}
bool HalStorage::begin() {
#if CROSSPOINT_EMULATED == 0
return SdMan.begin();
#else
// no-op
return true;
#endif
}
bool HalStorage::ready() const {
#if CROSSPOINT_EMULATED == 0
return SdMan.ready();
#else
// no-op
return true;
#endif
}
std::vector<String> HalStorage::listFiles(const char* path, int maxFiles) {
#if CROSSPOINT_EMULATED == 0
return SdMan.listFiles(path, maxFiles);
#else
Serial.printf("[%lu] [FS ] Emulated listFiles: %s\n", millis(), path);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_LIST, path);
std::vector<String> output;
for (int i = 0; i < maxFiles; ++i) {
auto resp = EmulationUtils::recvRespStr();
if (resp.length() == 0) {
break;
}
output.push_back(resp);
}
return output;
#endif
}
String HalStorage::readFile(const char* path) {
#if CROSSPOINT_EMULATED == 0
return SdMan.readFile(path);
#else
Serial.printf("[%lu] [FS ] Emulated readFile: %s\n", millis(), path);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_READ, path, "0", "-1");
return EmulationUtils::recvRespStr();
#endif
}
static int64_t getFileSizeEmulated(const char* path) {
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_STAT, path);
return EmulationUtils::recvRespInt64();
}
bool HalStorage::readFileToStream(const char* path, Print& out, size_t chunkSize) {
#if CROSSPOINT_EMULATED == 0
return SdMan.readFileToStream(path, out, chunkSize);
#else
Serial.printf("[%lu] [FS ] Emulated readFileToStream: %s\n", millis(), path);
auto size = getFileSizeEmulated(path);
if (size == -1) {
Serial.printf("[%lu] [FS ] File not found: %s\n", millis(), path);
return false;
}
if (size == -2) {
Serial.printf("[%lu] [FS ] Path is a directory, not a file: %s\n", millis(), path);
return false;
}
size_t bytesRead = 0;
while (bytesRead < static_cast<size_t>(size)) {
size_t toRead = std::min(chunkSize, static_cast<size_t>(size) - bytesRead);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_READ, path, String(bytesRead).c_str(), String(toRead).c_str());
auto buf = EmulationUtils::recvRespBuf();
out.write(buf.data(), buf.size());
bytesRead += buf.size();
}
return true;
#endif
}
size_t HalStorage::readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes) {
#if CROSSPOINT_EMULATED == 0
return SdMan.readFileToBuffer(path, buffer, bufferSize, maxBytes);
#else
Serial.printf("[%lu] [FS ] Emulated readFileToBuffer: %s\n", millis(), path);
auto size = getFileSizeEmulated(path);
if (size == -1) {
Serial.printf("[%lu] [FS ] File not found: %s\n", millis(), path);
return 0;
}
if (size == -2) {
Serial.printf("[%lu] [FS ] Path is a directory, not a file: %s\n", millis(), path);
return 0;
}
size_t toRead = static_cast<size_t>(size);
if (maxBytes > 0 && maxBytes < toRead) {
toRead = maxBytes;
}
if (toRead >= bufferSize) {
toRead = bufferSize - 1; // leave space for null terminator
}
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_READ, path, "0", String(toRead).c_str());
auto buf = EmulationUtils::recvRespBuf();
size_t bytesRead = buf.size();
memcpy(buffer, buf.data(), bytesRead);
buffer[bytesRead] = '\0'; // null-terminate
return bytesRead;
#endif
}
bool HalStorage::writeFile(const char* path, const String& content) {
#if CROSSPOINT_EMULATED == 0
return SdMan.writeFile(path, content);
#else
Serial.printf("[%lu] [FS ] Emulated writeFile: %s\n", millis(), path);
std::string b64 = EmulationUtils::base64_encode((char*)content.c_str(), content.length());
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_WRITE, path, b64.c_str(), "0", "0");
EmulationUtils::recvRespInt64(); // unused for now
return true;
#endif
}
bool HalStorage::ensureDirectoryExists(const char* path) {
#if CROSSPOINT_EMULATED == 0
return SdMan.ensureDirectoryExists(path);
#else
Serial.printf("[%lu] [FS ] Emulated ensureDirectoryExists: %s\n", millis(), path);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_MKDIR, path);
EmulationUtils::recvRespInt64(); // unused for now
return true;
#endif
}
FsFile HalStorage::open(const char* path, const oflag_t oflag) {
#if CROSSPOINT_EMULATED == 0
return SdMan.open(path, oflag);
#else
// TODO: do we need to check existence or create the file?
return FsFile(path, oflag);
#endif
}
bool HalStorage::mkdir(const char* path, const bool pFlag) {
#if CROSSPOINT_EMULATED == 0
return SdMan.mkdir(path, pFlag);
#else
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_MKDIR, path);
EmulationUtils::recvRespInt64(); // unused for now
return true;
#endif
}
bool HalStorage::exists(const char* path) {
#if CROSSPOINT_EMULATED == 0
return SdMan.exists(path);
#else
Serial.printf("[%lu] [FS ] Emulated exists: %s\n", millis(), path);
auto size = getFileSizeEmulated(path);
return size != -1;
#endif
}
bool HalStorage::remove(const char* path) {
#if CROSSPOINT_EMULATED == 0
return SdMan.remove(path);
#else
Serial.printf("[%lu] [FS ] Emulated remove: %s\n", millis(), path);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_RM, path);
EmulationUtils::recvRespInt64(); // unused for now
return true;
#endif
}
bool HalStorage::rmdir(const char* path) {
#if CROSSPOINT_EMULATED == 0
return SdMan.rmdir(path);
#else
Serial.printf("[%lu] [FS ] Emulated rmdir: %s\n", millis(), path);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_RM, path);
EmulationUtils::recvRespInt64(); // unused for now
return true;
#endif
}
bool HalStorage::openFileForRead(const char* moduleName, const char* path, FsFile& file) {
#if CROSSPOINT_EMULATED == 0
return SdMan.openFileForRead(moduleName, path, file);
#else
Serial.printf("[%lu] [FS ] Emulated openFileForRead: %s\n", millis(), path);
auto size = getFileSizeEmulated(path);
if (size == -1) {
Serial.printf("[%lu] [FS ] File not found: %s\n", millis(), path);
return false;
}
if (size == -2) {
Serial.printf("[%lu] [FS ] Path is a directory, not a file: %s\n", millis(), path);
return false;
}
return true;
#endif
}
bool HalStorage::openFileForRead(const char* moduleName, const std::string& path, FsFile& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForRead(const char* moduleName, const String& path, FsFile& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const char* path, FsFile& file) {
#if CROSSPOINT_EMULATED == 0
return SdMan.openFileForWrite(moduleName, path, file);
#else
Serial.printf("[%lu] [FS ] Emulated openFileForWrite: %s\n", millis(), path);
auto size = getFileSizeEmulated(path);
if (size == -1) {
Serial.printf("[%lu] [FS ] File not found: %s\n", millis(), path);
return false;
}
if (size == -2) {
Serial.printf("[%lu] [FS ] Path is a directory, not a file: %s\n", millis(), path);
return false;
}
return true;
#endif
}
bool HalStorage::openFileForWrite(const char* moduleName, const std::string& path, FsFile& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const String& path, FsFile& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool HalStorage::removeDir(const char* path) {
#if CROSSPOINT_EMULATED == 0
return SdMan.removeDir(path);
#else
// to be implemented
return false;
#endif
}
#if CROSSPOINT_EMULATED == 1
//
// FsFile emulation methods
//
FsFile::FsFile(const char* path, oflag_t oflag) : path(path), oflag(oflag) {
Serial.printf("[%lu] [FS ] Emulated FsFile open: %s\n", millis(), path);
auto size = getFileSizeEmulated(path);
if (size == -1) {
Serial.printf("[%lu] [FS ] File not found: %s\n", millis(), path);
open = false;
fileSizeBytes = 0;
} else if (isDir) {
Serial.printf("[%lu] [FS ] Path is a directory: %s\n", millis(), path);
open = true;
// get directory entries
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_LIST, path);
dirEntries.clear();
while (true) {
auto resp = EmulationUtils::recvRespStr();
if (resp.length() == 0) {
break;
}
dirEntries.push_back(resp);
}
Serial.printf("[%lu] [FS ] Directory has %u entries\n", millis(), (unsigned)dirEntries.size());
dirIndex = 0;
fileSizeBytes = 0;
} else {
open = true;
fileSizeBytes = static_cast<size_t>(size);
}
}
int FsFile::read(void* buf, size_t count) {
if (!open || isDir) return -1;
size_t bytesAvailable = (fileSizeBytes > filePos) ? (fileSizeBytes - filePos) : 0;
if (bytesAvailable == 0) return 0;
size_t toRead = std::min(count, bytesAvailable);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_READ, path.c_str(), String(filePos).c_str(), String(toRead).c_str());
auto data = EmulationUtils::recvRespBuf();
size_t bytesRead = data.size();
memcpy(buf, data.data(), bytesRead);
filePos += bytesRead;
return static_cast<int>(bytesRead);
}
int FsFile::read() {
uint8_t b;
int result = read(&b, 1);
if (result <= 0) return -1;
return b;
}
size_t FsFile::write(const uint8_t* buffer, size_t size) {
if (!open || isDir) return 0;
std::string b64 = EmulationUtils::base64_encode((const char*)buffer, size);
EmulationUtils::sendCmd(EmulationUtils::CMD_FS_WRITE, path.c_str(), b64.c_str(), String(filePos).c_str(), "1");
EmulationUtils::recvRespInt64(); // unused for now
filePos += size;
if (filePos > fileSizeBytes) {
fileSizeBytes = filePos;
}
return size;
}
size_t FsFile::write(uint8_t b) {
return write(&b, 1);
}
#endif

120
lib/hal/HalStorage.h Normal file
View File

@ -0,0 +1,120 @@
#pragma once
#include <Arduino.h>
#include <vector>
#if CROSSPOINT_EMULATED == 0
#include <SDCardManager.h>
#else
typedef int oflag_t;
#define O_RDONLY 0 /* +1 == FREAD */
#define O_WRONLY 1 /* +1 == FWRITE */
#define O_RDWR 2 /* +1 == FREAD|FWRITE */
class FsFile : public Print {
String path;
String name;
oflag_t oflag; // unused for now
bool open = false;
// directory state
bool isDir = false;
std::vector<String> dirEntries;
size_t dirIndex = 0;
// file state
size_t filePos = 0;
size_t fileSizeBytes = 0;
public:
FsFile() = default;
FsFile(const char* path, oflag_t oflag);
~FsFile() = default;
void flush() { /* no-op */ }
size_t getName(char* name, size_t len) {
String n = path;
if (n.length() >= len) {
n = n.substring(0, len - 1);
}
n.toCharArray(name, len);
return n.length();
}
size_t size() { return fileSizeBytes; }
size_t fileSize() { return size(); }
size_t seek(size_t pos) {
filePos = pos;
return filePos;
}
size_t seekCur(int64_t offset) { return seek(filePos + offset); }
size_t seekSet(size_t offset) { return seek(offset); }
int available() const { return (fileSizeBytes > filePos) ? (fileSizeBytes - filePos) : 0; }
size_t position() const { return filePos; }
int read(void* buf, size_t count);
int read(); // read a single byte
size_t write(const uint8_t* buffer, size_t size);
size_t write(uint8_t b) override;
bool isDirectory() const { return isDir; }
int rewindDirectory() {
if (!isDir) return -1;
dirIndex = 0;
return 0;
}
bool close() { open = false; return true; }
FsFile openNextFile() {
if (!isDir || dirIndex >= dirEntries.size()) {
return FsFile();
}
FsFile f(dirEntries[dirIndex].c_str(), O_RDONLY);
dirIndex++;
return f;
}
bool isOpen() const { return open; }
operator bool() const { return isOpen(); }
};
#endif
class HalStorage {
public:
HalStorage();
bool begin();
bool ready() const;
std::vector<String> listFiles(const char* path = "/", int maxFiles = 200);
// Read the entire file at `path` into a String. Returns empty string on failure.
String readFile(const char* path);
// Low-memory helpers:
// Stream the file contents to a `Print` (e.g. `Serial`, or any `Print`-derived object).
// Returns true on success, false on failure.
bool readFileToStream(const char* path, Print& out, size_t chunkSize = 256);
// Read up to `bufferSize-1` bytes into `buffer`, null-terminating it. Returns bytes read.
size_t readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes = 0);
// Write a string to `path` on the SD card. Overwrites existing file.
// Returns true on success.
bool writeFile(const char* path, const String& content);
// Ensure a directory exists, creating it if necessary. Returns true on success.
bool ensureDirectoryExists(const char* path);
FsFile open(const char* path, const oflag_t oflag = O_RDONLY);
bool mkdir(const char* path, const bool pFlag = true);
bool exists(const char* path);
bool remove(const char* path);
bool rmdir(const char* path);
bool openFileForRead(const char* moduleName, const char* path, FsFile& file);
bool openFileForRead(const char* moduleName, const std::string& path, FsFile& file);
bool openFileForRead(const char* moduleName, const String& path, FsFile& file);
bool openFileForWrite(const char* moduleName, const char* path, FsFile& file);
bool openFileForWrite(const char* moduleName, const std::string& path, FsFile& file);
bool openFileForWrite(const char* moduleName, const String& path, FsFile& file);
bool removeDir(const char* path);
static HalStorage& getInstance() { return instance; }
private:
static HalStorage instance;
bool is_emulated = CROSSPOINT_EMULATED;
};
// TODO @ngxson : this is a trick to avoid changing too many files at once.
// consider refactoring in a dedicated PR later.
#if CROSSPOINT_EMULATED == 1
#define SdMan HalStorage::getInstance()
#endif

View File

@ -20,7 +20,6 @@ board_upload.offset_address = 0x10000
build_flags = build_flags =
-DARDUINO_USB_MODE=1 -DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1
-DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 -DEINK_DISPLAY_SINGLE_BUFFER_MODE=1
-DDISABLE_FS_H_WARNING=1 -DDISABLE_FS_H_WARNING=1
@ -54,9 +53,21 @@ extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\" -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
-DCROSSPOINT_EMULATED=0
-DARDUINO_USB_CDC_ON_BOOT=1
[env:emulation]
extends = base
build_flags =
${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-emu\"
-DCROSSPOINT_EMULATED=1
-DARDUINO_USB_CDC_ON_BOOT=0 # Disable USB CDC on boot for Serial output via UART
[env:gh_release] [env:gh_release]
extends = base extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}\" -DCROSSPOINT_VERSION=\"${crosspoint.version}\"
-DCROSSPOINT_EMULATED=0
-DARDUINO_USB_CDC_ON_BOOT=1

View File

@ -0,0 +1,46 @@
FROM debian:12
WORKDIR /root
ARG DEBIAN_FRONTEND=noninteractive
ARG ESP_IDF_VERSION=v5.5.2
ARG ESP_QEMU_VERSION=esp-develop-9.2.2-20250817
# Update container
RUN apt-get update && apt-get upgrade -y
# Install dependencies
RUN apt-get install -y git wget flex bison gperf python3 python3-pip python3-setuptools python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0 \
libgcrypt20 libglib2.0-0 libpixman-1-0 libsdl2-2.0-0 libslirp0 \
libglib2.0-dev libpixman-1-dev libgcrypt20-dev libslirp-dev libsdl2-dev
# Install ESP-IDF
ENV PATH="~/.local/bin:${PATH}"
RUN mkdir -p ~/esp && \
cd ~/esp && git clone --recursive https://github.com/espressif/esp-idf.git -b ${ESP_IDF_VERSION} --depth 1 -j $(nproc) && \
ln -s /usr/bin/python3 /usr/bin/python
RUN cd ~/esp/esp-idf && export IDF_GITHUB_ASSETS="dl.espressif.com/github_assets" && ./install.sh && /bin/bash -c "source ./export.sh"
# Install qemu
RUN cd ~/esp/esp-idf && python tools/idf_tools.py install qemu-riscv32 qemu-xtensa
# Or, build qemu from source
# RUN git clone https://github.com/espressif/qemu.git -b ${ESP_QEMU_VERSION} --depth 1 \
# && cd qemu \
# && mkdir -p build \
# && cd build \
# && ../configure --target-list=riscv32-softmmu \
# --enable-gcrypt \
# --enable-slirp \
# --enable-debug \
# --enable-sdl \
# --disable-strip --disable-user \
# --disable-capstone --disable-vnc \
# --disable-gtk \
# && make -j $(nproc) vga=no \
# && make install
RUN echo "alias idf='source /root/esp/esp-idf/export.sh'" >> .bashrc
# Start from a Bash prompt
CMD [ "/bin/bash" ]

View File

@ -0,0 +1,35 @@
services:
crosspoint-emulator:
build: .
container_name: crosspoint-emulator
volumes:
- ../..:/app:ro
- ../../.sdcard:/sdcard:rw
tmpfs:
- /tmp
ports:
- "8090:8090"
stdin_open: true
tty: true
stop_signal: SIGKILL
entrypoint:
- /bin/bash
- -c
- |
set -x;
set -e;
source /root/esp/esp-idf/export.sh
cd /tmp
cp /app/.pio/build/emulation/*.bin .
dd if=/dev/zero bs=1M count=16 of=./flash.bin
dd if="bootloader.bin" bs=1 count=$(stat -c%s "bootloader.bin") seek=0 conv=notrunc of=./flash.bin
dd if="partitions.bin" bs=1 count=$(stat -c%s "partitions.bin") seek=$((16#8000)) conv=notrunc of=./flash.bin
dd if="firmware.bin" bs=1 count=$(stat -c%s "firmware.bin") seek=$((16#10000)) conv=notrunc of=./flash.bin
# TODO: why pip install doesn't work in Dockerfile?
python3 -m pip install websockets --break-system-packages
cp /app/scripts/emulation/web_ui.html .
python3 /app/scripts/emulation/server.py

549
scripts/emulation/server.py Normal file
View File

@ -0,0 +1,549 @@
import asyncio
import base64
import json
import os
import re
import shutil
import signal
import sys
from http import HTTPStatus
from pathlib import Path
from typing import Set
import websockets
from websockets.http11 import Response
from websockets.server import WebSocketServerProtocol
"""
This script spawns a QEMU process, then forward its stdout and stderr to WebSocket clients.
"""
# SD card emulation directory
sdcard_path = os.environ.get("SDCARD_PATH", "/sdcard")
if not os.path.exists(sdcard_path):
os.makedirs(sdcard_path)
class SdCardHandler:
"""Handle SD card filesystem commands from the emulated device."""
def __init__(self, base_path: str):
self.base_path = base_path
def _resolve_path(self, path: str) -> str:
"""Resolve a device path to a host filesystem path."""
# Remove leading slash and join with base path
if path.startswith("/"):
path = path[1:]
return os.path.join(self.base_path, path)
def handle_fs_list(self, path: str, max_files: str = "200") -> list[str]:
"""List files in a directory. Returns list of filenames."""
resolved = self._resolve_path(path)
max_count = int(max_files) if max_files else 200
if not os.path.exists(resolved):
return []
if not os.path.isdir(resolved):
return []
try:
entries = []
for entry in os.listdir(resolved):
if len(entries) >= max_count:
break
full_path = os.path.join(resolved, entry)
# Append "/" for directories to match device behavior
if os.path.isdir(full_path):
entries.append(entry + "/")
else:
entries.append(entry)
return entries
except Exception as e:
print(f"[SdCard] Error listing {path}: {e}", file=sys.stderr)
return []
def handle_fs_read(self, path: str, offset: str = "0", length: str = "-1") -> str:
"""Read file contents. Returns base64-encoded data."""
resolved = self._resolve_path(path)
offset_int = int(offset) if offset else 0
length_int = int(length) if length else -1
if not os.path.exists(resolved) or os.path.isdir(resolved):
return ""
try:
with open(resolved, "rb") as f:
if offset_int > 0:
f.seek(offset_int)
if length_int == -1:
data = f.read()
else:
data = f.read(length_int)
return base64.b64encode(data).decode("ascii")
except Exception as e:
print(f"[SdCard] Error reading {path}: {e}", file=sys.stderr)
return ""
def handle_fs_stat(self, path: str) -> int:
"""Get file size. Returns -1 if not found, -2 if directory."""
resolved = self._resolve_path(path)
if not os.path.exists(resolved):
return -1
if os.path.isdir(resolved):
return -2
try:
return os.path.getsize(resolved)
except Exception as e:
print(f"[SdCard] Error stat {path}: {e}", file=sys.stderr)
return -1
def handle_fs_write(self, path: str, b64_data: str, offset: str = "0", inplace: str = "0") -> int:
"""Write data to file. Returns bytes written."""
resolved = self._resolve_path(path)
offset_int = int(offset) if offset else 0
is_inplace = inplace == "1"
try:
data = base64.b64decode(b64_data)
except Exception as e:
print(f"[SdCard] Error decoding base64 for {path}: {e}", file=sys.stderr)
return 0
try:
# Ensure parent directory exists
parent = os.path.dirname(resolved)
if parent and not os.path.exists(parent):
os.makedirs(parent)
if is_inplace and os.path.exists(resolved):
# In-place write at offset
with open(resolved, "r+b") as f:
f.seek(offset_int)
f.write(data)
else:
# Overwrite or create new file
mode = "r+b" if os.path.exists(resolved) and offset_int > 0 else "wb"
with open(resolved, mode) as f:
if offset_int > 0:
f.seek(offset_int)
f.write(data)
return len(data)
except Exception as e:
print(f"[SdCard] Error writing {path}: {e}", file=sys.stderr)
return 0
def handle_fs_mkdir(self, path: str) -> int:
"""Create directory. Returns 0 on success."""
resolved = self._resolve_path(path)
try:
os.makedirs(resolved, exist_ok=True)
return 0
except Exception as e:
print(f"[SdCard] Error mkdir {path}: {e}", file=sys.stderr)
return -1
def handle_fs_rm(self, path: str) -> int:
"""Remove file or directory. Returns 0 on success."""
resolved = self._resolve_path(path)
if not os.path.exists(resolved):
return -1
try:
if os.path.isdir(resolved):
shutil.rmtree(resolved)
else:
os.remove(resolved)
return 0
except Exception as e:
print(f"[SdCard] Error removing {path}: {e}", file=sys.stderr)
return -1
# Global SD card handler instance
sdcard_handler = SdCardHandler(sdcard_path)
# WebSocket clients
connected_clients: Set[WebSocketServerProtocol] = set()
# QEMU process
qemu_process: asyncio.subprocess.Process | None = None
# Command pattern: $$CMD_(COMMAND):(ARG0)[:(ARG1)][:(ARG2)][:(ARG3)]$$
CMD_PATTERN = re.compile(r'^\$\$CMD_([A-Z_]+):(.+)\$\$$')
def parse_command(message: str) -> tuple[str, list[str]] | None:
"""Parse a command message. Returns (command, args) or None if not a command."""
match = CMD_PATTERN.match(message)
if not match:
return None
command = match.group(1)
args_str = match.group(2)
# Split args by ':' but the last arg might contain ':'
args = args_str.split(':')
return (command, args)
async def send_response(response: str):
"""Send a response back to the QEMU process via stdin."""
global qemu_process
if qemu_process and qemu_process.stdin:
qemu_process.stdin.write((response + "\n").encode())
await qemu_process.stdin.drain()
LAST_DISPLAY_BUFFER = None
BUTTON_STATE = 0
async def handle_command(command: str, args: list[str]) -> bool:
"""Handle a command from the device. Returns True if handled."""
global sdcard_handler, LAST_DISPLAY_BUFFER, BUTTON_STATE
try:
if command == "DISPLAY":
# Display command - no response needed
# arg0: base64-encoded buffer
print(f"[Emulator] DISPLAY command received (buffer size: {len(args[0]) if args else 0})", flush=True)
LAST_DISPLAY_BUFFER = args[0] if args else None
return True
elif command == "FS_LIST":
# arg0: path, arg1: max files (optional)
path = args[0] if len(args) > 0 else "/"
max_files = args[1] if len(args) > 1 else "200"
entries = sdcard_handler.handle_fs_list(path, max_files)
print(f"[Emulator] FS_LIST {path} -> {len(entries)} entries", flush=True)
# Send each entry as a line, then empty line to terminate
for entry in entries:
await send_response(entry)
await send_response("") # Empty line terminates the list
return True
elif command == "FS_READ":
# arg0: path, arg1: offset, arg2: length
path = args[0] if len(args) > 0 else ""
offset = args[1] if len(args) > 1 else "0"
length = args[2] if len(args) > 2 else "-1"
result = sdcard_handler.handle_fs_read(path, offset, length)
print(f"[Emulator] FS_READ {path} offset={offset} len={length} -> {len(result)} bytes (b64)", flush=True)
await send_response(result)
return True
elif command == "FS_STAT":
# arg0: path
path = args[0] if len(args) > 0 else ""
result = sdcard_handler.handle_fs_stat(path)
print(f"[Emulator] FS_STAT {path} -> {result}", flush=True)
await send_response(str(result))
return True
elif command == "FS_WRITE":
# arg0: path, arg1: base64-encoded data, arg2: offset, arg3: is inplace
path = args[0] if len(args) > 0 else ""
b64_data = args[1] if len(args) > 1 else ""
offset = args[2] if len(args) > 2 else "0"
inplace = args[3] if len(args) > 3 else "0"
result = sdcard_handler.handle_fs_write(path, b64_data, offset, inplace)
print(f"[Emulator] FS_WRITE {path} offset={offset} inplace={inplace} -> {result} bytes", flush=True)
await send_response(str(result))
return True
elif command == "FS_MKDIR":
# arg0: path
path = args[0] if len(args) > 0 else ""
result = sdcard_handler.handle_fs_mkdir(path)
print(f"[Emulator] FS_MKDIR {path} -> {result}", flush=True)
await send_response(str(result))
return True
elif command == "FS_RM":
# arg0: path
path = args[0] if len(args) > 0 else ""
result = sdcard_handler.handle_fs_rm(path)
print(f"[Emulator] FS_RM {path} -> {result}", flush=True)
await send_response(str(result))
return True
elif command == "BUTTON":
# arg0: action
action = args[0]
if action != "read":
raise ValueError("Only read action is supported")
# no log (too frequent)
# print(f"[Emulator] BUTTON command received: {action}", flush=True)
await send_response(str(BUTTON_STATE))
return True
else:
print(f"[Emulator] Unknown command: {command}", flush=True)
return False
except Exception as e:
print(f"[Emulator] Error handling command {command}: {e}", file=sys.stderr)
return False
def process_message(message: str, for_ui: bool) -> str | None:
if message.startswith("$$CMD_BUTTON:"):
return None # Too frequent, ignore
if message.startswith("$$CMD_"):
if for_ui:
return message
else:
# $$CMD_(COMMAND)[:(ARG0)][:(ARG1)][:(ARG2)]$$
command = message[2:].split(':')[0]
return "[Emulator] Received command: " + command
return message
async def broadcast_message(msg_type: str, data: str):
"""Broadcast a message to all connected WebSocket clients."""
if not connected_clients:
return
message = json.dumps({"type": msg_type, "data": data})
# Send to all clients, remove disconnected ones
disconnected = set()
for client in connected_clients:
try:
await client.send(message)
except websockets.exceptions.ConnectionClosed:
disconnected.add(client)
connected_clients.difference_update(disconnected)
async def read_stream(stream: asyncio.StreamReader, stream_type: str):
"""Read from a stream line by line and broadcast to clients."""
buffer = b""
while True:
try:
chunk = await stream.read(1024)
if not chunk:
break
buffer += chunk
# Process complete lines
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
try:
decoded_line = line.decode("utf-8", errors="replace")
except Exception:
decoded_line = line.decode("latin-1", errors="replace")
# Check if this is a command that needs handling
parsed = parse_command(decoded_line)
if parsed:
command, args = parsed
await handle_command(command, args)
if command == "DISPLAY":
await broadcast_message(stream_type, decoded_line)
continue
to_stdio = process_message(decoded_line, for_ui=False)
to_ws = process_message(decoded_line, for_ui=True)
# Forward to parent process
if to_stdio is not None:
if stream_type == "stdout":
print(to_stdio, flush=True)
else:
print(to_stdio, file=sys.stderr, flush=True)
# Broadcast to WebSocket clients
if to_ws is not None:
await broadcast_message(stream_type, to_ws)
except Exception as e:
print(f"Error reading {stream_type}: {e}", file=sys.stderr)
break
# Process remaining buffer
if buffer:
try:
decoded_line = buffer.decode("utf-8", errors="replace")
except Exception:
decoded_line = buffer.decode("latin-1", errors="replace")
if stream_type == "stdout":
print(decoded_line, flush=True)
else:
print(decoded_line, file=sys.stderr, flush=True)
await broadcast_message(stream_type, decoded_line)
async def spawn_qemu():
"""Spawn the QEMU process and capture its output."""
global qemu_process
# Build the command
cmd = [
"qemu-system-riscv32",
"-nographic",
"-M", "esp32c3",
"-drive", "file=flash.bin,if=mtd,format=raw",
# "-global", "driver=timer.esp32c3.timg,property=wdt_disable,value=true", # got panic if we don't disable WDT, why?
]
# Get working directory from environment or use /tmp
work_dir = os.getcwd()
print(f"Starting QEMU in {work_dir}...", flush=True)
print(f"Command: {' '.join(cmd)}", flush=True)
try:
qemu_process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
cwd=work_dir,
env=os.environ.copy()
)
# Read stdout and stderr concurrently
await asyncio.gather(
read_stream(qemu_process.stdout, "stdout"),
read_stream(qemu_process.stderr, "stderr")
)
# Wait for process to complete
await qemu_process.wait()
print(f"QEMU process exited with code {qemu_process.returncode}", flush=True)
except FileNotFoundError:
print("Error: qemu-system-riscv32 not found. Make sure it's in PATH.", file=sys.stderr)
await broadcast_message("stderr", "Error: qemu-system-riscv32 not found")
except Exception as e:
print(f"Error spawning QEMU: {e}", file=sys.stderr)
await broadcast_message("stderr", f"Error spawning QEMU: {e}")
async def websocket_handler(websocket: WebSocketServerProtocol):
"""Handle a WebSocket connection."""
connected_clients.add(websocket)
print(f"Client connected. Total clients: {len(connected_clients)}", flush=True)
try:
# Send a welcome message
await websocket.send(json.dumps({
"type": "info",
"data": "Connected to CrossPoint emulator"
}))
# Send the last display buffer if available
if LAST_DISPLAY_BUFFER is not None:
await websocket.send(json.dumps({
"type": "stdout",
"data": f"$$CMD_DISPLAY:{LAST_DISPLAY_BUFFER}$$"
}))
# Handle incoming messages (for stdin forwarding and events)
async for message in websocket:
try:
data = json.loads(message)
msg_type = data.get("type")
if msg_type == "stdin" and qemu_process and qemu_process.stdin:
input_data = data.get("data", "")
qemu_process.stdin.write((input_data + "\n").encode())
await qemu_process.stdin.drain()
elif msg_type == "button_state":
global BUTTON_STATE
BUTTON_STATE = int(data.get("state", "0"))
print(f"[WebUI] Button state updated to {BUTTON_STATE}", flush=True)
else:
# Print any other messages for debugging
print(f"[WebUI Message] {message}", flush=True)
except json.JSONDecodeError:
print(f"[WebUI] Invalid JSON received: {message}", file=sys.stderr)
except Exception as e:
print(f"Error handling client message: {e}", file=sys.stderr)
except websockets.exceptions.ConnectionClosed:
pass
finally:
connected_clients.discard(websocket)
print(f"Client disconnected. Total clients: {len(connected_clients)}", flush=True)
async def main():
"""Main entry point."""
host = os.environ.get("HOST", "0.0.0.0")
port = int(os.environ.get("PORT", "8090"))
print(f"Starting WebSocket server on {host}:{port}", flush=True)
# Start WebSocket server
async with websockets.serve(
websocket_handler, host, port,
process_request=process_request,
origins=None,
):
print(f"WebSocket server running on ws://{host}:{port}/ws", flush=True)
print(f"Web UI available at http://{host}:{port}/", flush=True)
# Spawn QEMU process
await spawn_qemu()
def signal_handler(signum, frame):
"""Handle shutdown signals."""
print("\nShutting down...", flush=True)
if qemu_process:
qemu_process.terminate()
sys.exit(0)
def process_request(connection, request):
"""Handle HTTP requests for serving static files."""
path = request.path
if path == "/" or path == "/web_ui.html":
# Serve the web_ui.html file
html_path = Path(__file__).parent / "web_ui.html"
try:
content = html_path.read_bytes()
return Response(
HTTPStatus.OK,
"OK",
websockets.Headers([
("Content-Type", "text/html; charset=utf-8"),
("Content-Length", str(len(content))),
]),
content
)
except FileNotFoundError:
return Response(HTTPStatus.NOT_FOUND, "Not Found", websockets.Headers(), b"web_ui.html not found")
if path == "/ws":
# Return None to continue with WebSocket handshake
return None
# Return 404 for other paths
return Response(HTTPStatus.NOT_FOUND, "Not Found", websockets.Headers(), b"Not found")
if __name__ == "__main__":
# Set up signal handlers
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Run the main loop
asyncio.run(main())

View File

@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CrossPoint Emulator</title>
<style>
body {
font-family: monospace;
margin: 0;
height: 100vh;
background-color: #bbb;
display: flex;
justify-content: center;
}
button {
font-size: 1.2em;
padding: 0.5em 1em;
margin: 0.2em;
}
.container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
max-width: 1200px;
}
.display, .control {
width: 50%;
height: 95vh;
display: flex;
justify-content: center;
align-items: center;
padding-left: 2em;
padding-right: 2em;
}
.display > canvas {
max-height: 100%;
}
.control {
flex-direction: column;
gap: 1em;
}
.control > .ctrl {
padding-top: 2em;
padding-bottom: 2em;
}
.control > #output {
height: 100%;
width: 100%;
padding: 10px;
overflow-y: scroll;
border: 1px solid #555;
}
</style>
</head>
<body>
<div class="container">
<div class="display">
<!-- Display -->
<canvas id="screen" width="480" height="800" style="border:1px solid #555;"></canvas>
</div>
<div class="control">
<!-- Control + Log -->
<div class="ctrl">
<button id="btn0">Btn 0</button>
<button id="btn1">Btn 1</button>
&nbsp;&nbsp;&nbsp;
<button id="btn2">Btn 2</button>
<button id="btn3">Btn 3</button>
&nbsp;&nbsp;&nbsp;
<button id="btnU">Btn Up</button>
<button id="btnD">Btn Down</button>
&nbsp;&nbsp;&nbsp;
<button id="btnP">Btn Power</button>
</div>
<div id="output"></div>
</div>
</div>
<script>
const output = document.getElementById('output');
const screen = document.getElementById('screen');
const ctx = screen.getContext('2d');
let ws = null;
const MAX_LOG_LINES = 1000;
function appendLog(type, message) {
const line = document.createElement('div');
line.className = type;
line.textContent = message;
output.appendChild(line);
output.scrollTop = output.scrollHeight; // TODO: only scroll if already at bottom
while (output.childElementCount > MAX_LOG_LINES) {
output.removeChild(output.firstChild);
}
}
function drawScreen(b64Data) {
// b64Data is a base64-encoded 1-bit per pixel framebuffer
// Source buffer is 800x480, with 100 bytes per row (800/8 = 100)
// We rotate 90 degrees clockwise to display as 480x800
const binaryString = atob(b64Data);
const srcWidth = 800;
const srcHeight = 480;
const srcWidthBytes = srcWidth / 8; // 100 bytes per row
// After 90° clockwise rotation: new width = srcHeight, new height = srcWidth
const dstWidth = srcHeight; // 480
const dstHeight = srcWidth; // 800
const imageData = ctx.createImageData(dstWidth, dstHeight);
const pixels = imageData.data;
for (let srcY = 0; srcY < srcHeight; srcY++) {
for (let xByte = 0; xByte < srcWidthBytes; xByte++) {
const byteIndex = srcY * srcWidthBytes + xByte;
const byte = binaryString.charCodeAt(byteIndex);
// Each byte contains 8 pixels (MSB first)
for (let bit = 0; bit < 8; bit++) {
const srcX = xByte * 8 + bit;
// 90° clockwise rotation: (srcX, srcY) -> (srcHeight - 1 - srcY, srcX)
const dstX = srcHeight - 1 - srcY;
const dstY = srcX;
const pixelIndex = (dstY * dstWidth + dstX) * 4;
// Bit 1 = white (0xFF), Bit 0 = black (0x00)
const isWhite = (byte >> (7 - bit)) & 1;
const color = isWhite ? 255 : 0;
pixels[pixelIndex] = color; // R
pixels[pixelIndex + 1] = color; // G
pixels[pixelIndex + 2] = color; // B
pixels[pixelIndex + 3] = 255; // A
}
}
}
ctx.putImageData(imageData, 0, 0);
}
let btn0Pressed = false;
let btn1Pressed = false;
let btn2Pressed = false;
let btn3Pressed = false;
let btnUpPressed = false;
let btnDownPressed = false;
let btnPowerPressed = false;
let buttonState = 0;
function sendButtonState() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'button_state',
state: buttonState
}));
appendLog('info', `Sent button state: ${buttonState}`);
} else {
appendLog('error', 'WebSocket not connected');
}
}
function setupButtonHandler(buttonId, bitIndex) {
const button = document.getElementById(buttonId);
button.onmousedown = () => {
buttonState ^= (1 << bitIndex);
sendButtonState();
};
button.onmouseup = () => {
buttonState &= ~(1 << bitIndex);
sendButtonState();
};
}
setupButtonHandler('btn0', 0);
setupButtonHandler('btn1', 1);
setupButtonHandler('btn2', 2);
setupButtonHandler('btn3', 3);
setupButtonHandler('btnU', 4);
setupButtonHandler('btnD', 5);
setupButtonHandler('btnP', 6);
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
appendLog('info', `Connecting to ${wsUrl}...`);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
appendLog('info', 'Connected');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.data.startsWith('$$CMD_')) {
// $$CMD_(COMMAND)[:(ARG0)][:(ARG1)][:(ARG2)]$$
const parts = msg.data.slice(2, -2).split(':');
const command = parts[0];
appendLog('cmd', `Received command: ${command}`);
if (command === 'CMD_DISPLAY') {
drawScreen(parts[1]);
}
} else {
appendLog(msg.type, msg.data);
}
} catch (e) {
appendLog('stdout', event.data);
}
};
ws.onclose = () => {
appendLog('info', 'Disconnected');
setTimeout(connect, 3000);
};
}
window.onload = () => {
connect();
// fill canvas with black initially
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, screen.width, screen.height);
};
</script>
</body>
</html>

View File

@ -3,4 +3,16 @@
#define BAT_GPIO0 0 // Battery voltage #define BAT_GPIO0 0 // Battery voltage
static BatteryMonitor battery(BAT_GPIO0); class Battery {
public:
uint16_t readPercentage() const {
#if CROSSPOINT_EMULATED == 0
static const BatteryMonitor hwBattery = BatteryMonitor(BAT_GPIO0);
return hwBattery.readPercentage();
#else
return 100;
#endif
}
};
static Battery battery;

View File

@ -1,7 +1,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
#include <cstring> #include <cstring>

View File

@ -1,7 +1,7 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
namespace { namespace {

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <InputManager.h> #include <HalInput.h>
class MappedInputManager { class MappedInputManager {
public: public:

View File

@ -1,7 +1,7 @@
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
#include <algorithm> #include <algorithm>

View File

@ -1,7 +1,7 @@
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
// Initialize the static instance // Initialize the static instance

View File

@ -2,7 +2,7 @@
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Txt.h> #include <Txt.h>
#include <Xtc.h> #include <Xtc.h>
@ -133,7 +133,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.invertScreen(); renderer.invertScreen();
} }
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
@ -180,7 +180,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y);
renderer.clearScreen(); renderer.clearScreen();
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
if (bitmap.hasGreyscale()) { if (bitmap.hasGreyscale()) {
bitmap.rewindToData(); bitmap.rewindToData();
@ -271,5 +271,5 @@ void SleepActivity::renderCoverSleepScreen() const {
void SleepActivity::renderBlankSleepScreen() const { void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen(); renderer.clearScreen();
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }

View File

@ -3,7 +3,7 @@
#include <Bitmap.h> #include <Bitmap.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Xtc.h> #include <Xtc.h>
#include <cstring> #include <cstring>

View File

@ -1,7 +1,7 @@
#include "MyLibraryActivity.h" #include "MyLibraryActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <algorithm> #include <algorithm>

View File

@ -2,7 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <WiFi.h> #include <WiFi.h>
#include <cstring> #include <cstring>

View File

@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SDCardManager.h> #include <HalStorage.h>
#include <WiFiClient.h> #include <WiFiClient.h>
#include <WiFiUdp.h> #include <WiFiUdp.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>

View File

@ -3,7 +3,7 @@
#include <Epub/Page.h> #include <Epub/Page.h>
#include <FsHelpers.h> #include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
@ -322,7 +322,7 @@ void EpubReaderActivity::renderScreen() {
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100; const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}; };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
@ -392,7 +392,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -1,7 +1,7 @@
#include "TxtReaderActivity.h" #include "TxtReaderActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
#include <Utf8.h> #include <Utf8.h>
@ -237,7 +237,7 @@ void TxtReaderActivity::buildPageIndex() {
// Fill progress bar // Fill progress bar
const int fillWidth = (barWidth - 2) * progressPercent / 100; const int fillWidth = (barWidth - 2) * progressPercent / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} }
// Yield to other tasks periodically // Yield to other tasks periodically
@ -465,7 +465,7 @@ void TxtReaderActivity::renderPage() {
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -9,7 +9,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
@ -269,7 +269,7 @@ void XtcReaderActivity::renderPage() {
// Display BW with conditional refresh based on pagesUntilFullRefresh // Display BW with conditional refresh based on pagesUntilFullRefresh
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();
@ -349,7 +349,7 @@ void XtcReaderActivity::renderPage() {
// Display with appropriate refresh // Display with appropriate refresh
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -2,7 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "fontIds.h" #include "fontIds.h"

View File

@ -1,5 +1,5 @@
#pragma once #pragma once
#include <EInkDisplay.h> #include <HalDisplay.h>
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <string> #include <string>
@ -10,12 +10,12 @@
class FullScreenMessageActivity final : public Activity { class FullScreenMessageActivity final : public Activity {
std::string text; std::string text;
EpdFontFamily::Style style; EpdFontFamily::Style style;
EInkDisplay::RefreshMode refreshMode; HalDisplay::RefreshMode refreshMode;
public: public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text, explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
const EpdFontFamily::Style style = EpdFontFamily::REGULAR, const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH)
: Activity("FullScreenMessage", renderer, mappedInput), : Activity("FullScreenMessage", renderer, mappedInput),
text(std::move(text)), text(std::move(text)),
style(style), style(style),

View File

@ -1,9 +1,9 @@
#include <Arduino.h> #include <Arduino.h>
#include <EInkDisplay.h> #include <HalDisplay.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <HalInput.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <SPI.h> #include <SPI.h>
#include <builtinFonts/all.h> #include <builtinFonts/all.h>
@ -39,7 +39,7 @@
#define SD_SPI_MISO 7 #define SD_SPI_MISO 7
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY); HalDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
InputManager inputManager; InputManager inputManager;
MappedInputManager mappedInputManager(inputManager); MappedInputManager mappedInputManager(inputManager);
GfxRenderer renderer(einkDisplay); GfxRenderer renderer(einkDisplay);
@ -184,12 +184,14 @@ void verifyWakeupLongPress() {
if (abort) { if (abort) {
// Button released too early. Returning to sleep. // Button released too early. Returning to sleep.
// IMPORTANT: Re-arm the wakeup trigger before sleeping again // IMPORTANT: Re-arm the wakeup trigger before sleeping again
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); startDeepSleep(inputManager);
esp_deep_sleep_start();
} }
} }
void waitForPowerRelease() { void waitForPowerRelease() {
if (CROSSPOINT_EMULATED) {
return;
}
inputManager.update(); inputManager.update();
while (inputManager.isPressed(InputManager::BTN_POWER)) { while (inputManager.isPressed(InputManager::BTN_POWER)) {
delay(50); delay(50);
@ -205,11 +207,7 @@ void enterDeepSleep() {
einkDisplay.deepSleep(); einkDisplay.deepSleep();
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1); Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); startDeepSleep(inputManager);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
waitForPowerRelease();
// Enter Deep Sleep
esp_deep_sleep_start();
} }
void onGoHome(); void onGoHome();
@ -278,10 +276,13 @@ void setupDisplayAndFonts() {
bool isUsbConnected() { bool isUsbConnected() {
// U0RXD/GPIO20 reads HIGH when USB is connected // U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH; return CROSSPOINT_EMULATED || digitalRead(UART0_RXD) == HIGH;
} }
bool isWakeupAfterFlashing() { bool isWakeupAfterFlashing() {
if (CROSSPOINT_EMULATED) {
return true;
}
const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason(); const auto resetReason = esp_reset_reason();

View File

@ -3,7 +3,7 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <Epub.h> #include <Epub.h>
#include <FsHelpers.h> #include <FsHelpers.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_task_wdt.h> #include <esp_task_wdt.h>

View File

@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SDCardManager.h> #include <HalStorage.h>
#include <functional> #include <functional>
#include <string> #include <string>