diff --git a/firmware/partitions/jcalendar_4mb_ota.csv b/firmware/partitions/jcalendar_4mb_ota.csv new file mode 100644 index 0000000..795cafc --- /dev/null +++ b/firmware/partitions/jcalendar_4mb_ota.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x1e0000, +app1, app, ota_1, 0x1f0000,0x1e0000, +spiffs, data, spiffs, 0x3d0000,0x20000, +coredump, data, coredump,0x3f0000,0x10000, diff --git a/firmware/platformio.ini b/firmware/platformio.ini index f932a94..87affde 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -53,6 +53,20 @@ build_flags = -DEPD_PANEL_42_SSD1683_BW -DALLOW_INSECURE_FALLBACK=0 +# ── J-Calendar ESP32 + GDEQ042Z21 4.2" BWR 三色屏 ────────────────────── +[env:epd_42_jcalendar_z21_esp32dev] +extends = common +board = esp32dev +upload_speed = 460800 +board_build.partitions = partitions/jcalendar_4mb_ota.csv +build_flags = + -DBOARD_PROFILE_JCALENDAR_ESP32 + -DEPD_WIDTH=400 + -DEPD_HEIGHT=300 + -DEPD_PANEL_42_Z21_BWR + -DEPD_BPP=2 + -DALLOW_INSECURE_FALLBACK=0 + # ── ESP32-WROOM32E + AI Chat (语音对话) ────────────────────── [env:epd_42_wroom32e_ai_chat] extends = common diff --git a/firmware/src/config.h b/firmware/src/config.h index 472bc49..137c118 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -25,6 +25,17 @@ #define PIN_CFG_BTN 9 #define PIN_LED 5 #define PIN_AI_CHAT_SW -1 +#elif defined(BOARD_PROFILE_JCALENDAR_ESP32) +#define PIN_EPD_MOSI 23 +#define PIN_EPD_SCK 18 +#define PIN_EPD_CS 5 +#define PIN_EPD_DC 17 +#define PIN_EPD_RST 16 +#define PIN_EPD_BUSY 4 +#define PIN_BAT_ADC 32 +#define PIN_CFG_BTN 14 +#define PIN_LED 22 +#define PIN_AI_CHAT_SW -1 #elif defined(BOARD_PROFILE_ESP32_WROOM32E) #define PIN_EPD_MOSI 14 #define PIN_EPD_SCK 13 diff --git a/firmware/src/epd_driver.cpp b/firmware/src/epd_driver.cpp index e36f15e..5b7a46c 100644 --- a/firmware/src/epd_driver.cpp +++ b/firmware/src/epd_driver.cpp @@ -785,6 +785,7 @@ void epdSleep() { #include #include +#include #ifndef EPD_GXEPD2_SPI_HZ #define EPD_GXEPD2_SPI_HZ 4000000 @@ -794,6 +795,10 @@ void epdSleep() { #include GxEPD2_BW display( GxEPD2_420_GDEY042T81(PIN_EPD_CS, PIN_EPD_DC, PIN_EPD_RST, PIN_EPD_BUSY)); +#elif defined(EPD_PANEL_42_Z21_BWR) + #include + GxEPD2_3C display( + GxEPD2_420c_Z21(PIN_EPD_CS, PIN_EPD_DC, PIN_EPD_RST, PIN_EPD_BUSY)); #elif defined(EPD_PANEL_42_GXEPD2_GYE042A87) #include GxEPD2_BW display( @@ -846,7 +851,7 @@ void epdSleep() { GxEPD2_BW display( GxEPD2_750_T7(PIN_EPD_CS, PIN_EPD_DC, PIN_EPD_RST, PIN_EPD_BUSY)); #else - #error "No EPD panel type defined. Use -DEPD_PANEL_42_SSD1683_BW, -DEPD_PANEL_42_DKE_RY683, -DEPD_PANEL_42_GDEM042F52, -DEPD_PANEL_42_GXEPD2_T81, -DEPD_PANEL_42_GXEPD2_GYE042A87, -DEPD_PANEL_42_GXEPD2_420, -DEPD_PANEL_42_GXEPD2_M01, -DEPD_PANEL_29, -DEPD_PANEL_583_UC8179, -DEPD_PANEL_583, or -DEPD_PANEL_75" + #error "No EPD panel type defined. Use -DEPD_PANEL_42_SSD1683_BW, -DEPD_PANEL_42_DKE_RY683, -DEPD_PANEL_42_GDEM042F52, -DEPD_PANEL_42_Z21_BWR, -DEPD_PANEL_42_GXEPD2_T81, -DEPD_PANEL_42_GXEPD2_GYE042A87, -DEPD_PANEL_42_GXEPD2_420, -DEPD_PANEL_42_GXEPD2_M01, -DEPD_PANEL_29, -DEPD_PANEL_583_UC8179, -DEPD_PANEL_583, or -DEPD_PANEL_75" #endif static bool _initialized = false; @@ -854,7 +859,7 @@ static bool _initialized = false; static bool _needs_full_refresh_write = true; #endif static const uint8_t DISPLAY_ROTATION = -#if defined(EPD_PANEL_42_GXEPD2_T81) || defined(EPD_PANEL_42_GXEPD2_GYE042A87) || defined(EPD_PANEL_42_GXEPD2_420) || defined(EPD_PANEL_42_GXEPD2_M01) +#if defined(EPD_PANEL_42_Z21_BWR) || defined(EPD_PANEL_42_GXEPD2_T81) || defined(EPD_PANEL_42_GXEPD2_GYE042A87) || defined(EPD_PANEL_42_GXEPD2_420) || defined(EPD_PANEL_42_GXEPD2_M01) 0; #else 1; @@ -878,9 +883,79 @@ void epdInitFast() { epdInit(); } +#if defined(EPD_PANEL_42_Z21_BWR) +static void z21SetPixel(uint8_t *buffer, int x, int y, bool active) { + int index = y * ROW_BYTES + x / 8; + uint8_t mask = 0x80 >> (x % 8); + if (active) { + buffer[index] &= ~mask; + } else { + buffer[index] |= mask; + } +} + +static bool z21IsRed2bppColor(uint8_t color) { + return color == 0x03 || color == 0x02; +} + +static void writeZ21FullImage(const uint8_t *blackBuf, const uint8_t *redBuf) { + epdInit(); + display.writeImage(blackBuf, redBuf, 0, 0, W, H, false, false, false); + display.refresh(false); + display.powerOff(); +} + +static void writeZ21TricolorImage(const uint8_t *image2bpp) { + uint8_t *blackBuf = (uint8_t *)malloc(IMG_BUF_LEN); + uint8_t *redBuf = (uint8_t *)malloc(IMG_BUF_LEN); + if (!blackBuf || !redBuf) { + Serial.println("[EPD] Z21 2bpp buffer alloc failed"); + free(blackBuf); + free(redBuf); + return; + } + + memset(blackBuf, 0xFF, IMG_BUF_LEN); + memset(redBuf, 0xFF, IMG_BUF_LEN); + + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + int index = (y * W + x) / 4; + int shift = 6 - (((y * W + x) % 4) * 2); + uint8_t color = (image2bpp[index] >> shift) & 0x03; + + if (color == 0x00) { + z21SetPixel(blackBuf, x, y, true); + } else if (z21IsRed2bppColor(color)) { + z21SetPixel(redBuf, x, y, true); + } + } + } + + writeZ21FullImage(blackBuf, redBuf); + free(blackBuf); + free(redBuf); +} + +static void writeZ21MonoImage(const uint8_t *image) { + uint8_t *redBuf = (uint8_t *)malloc(IMG_BUF_LEN); + if (!redBuf) { + Serial.println("[EPD] Z21 red buffer alloc failed"); + return; + } + + memset(redBuf, 0xFF, IMG_BUF_LEN); + writeZ21FullImage(image, redBuf); + free(redBuf); +} +#endif + void epdDisplay(const uint8_t *image) { epdInit(); -#if defined(EPD_PANEL_29) +#if defined(EPD_PANEL_42_Z21_BWR) + writeZ21MonoImage(image); + return; +#elif defined(EPD_PANEL_29) rotate_landscape_to_panel(image); display.writeImage( rotated_buffer, @@ -906,12 +981,25 @@ void epdDisplay(const uint8_t *image) { display.powerOff(); } +void epdDisplay2bpp(const uint8_t *image2bpp) { +#if defined(EPD_PANEL_42_Z21_BWR) + writeZ21TricolorImage(image2bpp); +#else + (void)image2bpp; + epdDisplay(imgBuf); +#endif +} + void epdDisplayFast(const uint8_t *image) { #if defined(EPD_PANEL_583_UC8179) // 583 UC8179: always full refresh (GxEPD2 refresh(false)); avoids partial LUT ghosting. epdDisplay(image); return; #endif +#if defined(EPD_PANEL_42_Z21_BWR) + epdDisplay(image); + return; +#endif #if defined(EPD_PANEL_42_GXEPD2_GYE042A87) epdDisplay(image); return; @@ -937,6 +1025,10 @@ void epdDisplayFast(const uint8_t *image) { } void epdDisplayDeepClear(const uint8_t *image) { +#if defined(EPD_PANEL_42_Z21_BWR) + epdDisplay(image); + return; +#endif epdInit(); uint8_t *clearBuf = (uint8_t *)malloc(IMG_BUF_LEN); @@ -958,7 +1050,7 @@ void epdPartialDisplay(uint8_t *data, int xStart, int yStart, int xEnd, int yEnd } bool epdSupportsPartialRefresh() { -#if defined(EPD_PANEL_29) +#if defined(EPD_PANEL_42_Z21_BWR) || defined(EPD_PANEL_29) return false; #else return true; @@ -967,7 +1059,16 @@ bool epdSupportsPartialRefresh() { void epdPartialDisplayWithOld(uint8_t *data, const uint8_t *oldData, int xStart, int yStart, int xEnd, int yEnd) { epdInit(); -#if defined(EPD_PANEL_29) +#if defined(EPD_PANEL_42_Z21_BWR) + (void)data; + (void)oldData; + (void)xStart; + (void)yStart; + (void)xEnd; + (void)yEnd; + epdDisplay(imgBuf); + return; +#elif defined(EPD_PANEL_29) (void)data; (void)oldData; (void)xStart; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 1c03e9e..c3fc712 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -104,6 +104,7 @@ struct DeviceContext { // Pending actions (set by button handler, consumed by loop) bool wantRefresh = false; + bool wantRefreshNext = false; bool wantEnterLiveMode = false; bool wantEnterAiChatMode = false; bool wantSingleVoiceTurn = false; @@ -1111,7 +1112,11 @@ void loop() { checkConfigButton(); checkAiChatButton(); - if (ctx.wantEnterLiveMode) { + if (ctx.wantRefreshNext) { + ctx.wantRefreshNext = false; + triggerImmediateRefresh(true, true); + ctx.setupDoneAt = millis(); + } else if (ctx.wantEnterLiveMode) { ctx.wantEnterLiveMode = false; if (ctx.liveMode) { ctx.liveMode = false; @@ -2209,8 +2214,8 @@ static void checkConfigButton() { if (pressDuration >= (unsigned long)SHORT_PRESS_MIN_MS && pressDuration < (unsigned long)CFG_BTN_HOLD_MS) { - Serial.println("[BTN] Single click -> toggle live mode"); - ctx.wantEnterLiveMode = true; + Serial.println("[BTN] Single click -> next content"); + ctx.wantRefreshNext = true; } } } diff --git a/firmware/src/network.cpp b/firmware/src/network.cpp index 1bc3e8a..f3a32b1 100644 --- a/firmware/src/network.cpp +++ b/firmware/src/network.cpp @@ -158,7 +158,24 @@ float readBatteryVoltage() { sum += readings[i]; float avgRaw = (float)sum / (SAMPLES - 2 * DISCARD); -#if defined(BOARD_PROFILE_ESP32_C3_WROOM02) +#if defined(BOARD_PROFILE_JCALENDAR_ESP32) + uint32_t pinMv = analogReadMilliVolts(PIN_BAT_ADC); + float realBatteryVoltage = (pinMv / 1000.0f) * 2.0f; + + const float measuredLow = 2.95f; + const float measuredHigh = 4.17f; + const float targetLow = 0.0f; + const float targetHigh = 3.3f; + + if (realBatteryVoltage <= measuredLow) return targetLow; + if (realBatteryVoltage >= measuredHigh) return targetHigh; + + float mappedVoltage = targetLow + (realBatteryVoltage - measuredLow) * + (targetHigh - targetLow) / (measuredHigh - measuredLow); + if (mappedVoltage > targetHigh) mappedVoltage = targetHigh; + if (mappedVoltage < targetLow) mappedVoltage = targetLow; + return mappedVoltage; +#elif defined(BOARD_PROFILE_ESP32_C3_WROOM02) static esp_adc_cal_characteristics_t adcChars; static bool calibrated = false; if (!calibrated) { diff --git a/firmware/tests/test_jcalendar_z21_profile.py b/firmware/tests/test_jcalendar_z21_profile.py new file mode 100644 index 0000000..c372d8f --- /dev/null +++ b/firmware/tests/test_jcalendar_z21_profile.py @@ -0,0 +1,158 @@ +import configparser +import pathlib +import re +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def read_text(relative_path): + return (ROOT / relative_path).read_text(encoding="utf-8") + + +class JCalendarZ21ProfileTest(unittest.TestCase): + def test_platformio_env_targets_jcalendar_z21_hardware(self): + parser = configparser.ConfigParser() + parser.optionxform = str + parser.read(ROOT / "platformio.ini", encoding="utf-8") + + env = "env:epd_42_jcalendar_z21_esp32dev" + self.assertIn(env, parser.sections()) + self.assertEqual(parser[env].get("extends"), "common") + self.assertEqual(parser[env].get("board"), "esp32dev") + self.assertEqual( + parser[env].get("board_build.partitions"), + "partitions/jcalendar_4mb_ota.csv", + ) + + flags = parser[env].get("build_flags", "") + expected_flags = { + "-DBOARD_PROFILE_JCALENDAR_ESP32", + "-DEPD_WIDTH=400", + "-DEPD_HEIGHT=300", + "-DEPD_PANEL_42_Z21_BWR", + "-DEPD_BPP=2", + "-DALLOW_INSECURE_FALLBACK=0", + } + for flag in expected_flags: + self.assertIn(flag, flags) + + def test_config_declares_exact_jcalendar_pinout_without_audio(self): + config_h = read_text("src/config.h") + match = re.search( + r"#elif defined\(BOARD_PROFILE_JCALENDAR_ESP32\)(.*?)#elif defined", + config_h, + flags=re.S, + ) + self.assertIsNotNone(match) + block = match.group(1) + + expected_pins = { + "PIN_EPD_MOSI": "23", + "PIN_EPD_SCK": "18", + "PIN_EPD_CS": "5", + "PIN_EPD_DC": "17", + "PIN_EPD_RST": "16", + "PIN_EPD_BUSY": "4", + "PIN_BAT_ADC": "32", + "PIN_CFG_BTN": "14", + "PIN_LED": "22", + "PIN_AI_CHAT_SW": "-1", + } + for name, value in expected_pins.items(): + self.assertRegex(block, rf"#define\s+{name}\s+{re.escape(value)}\b") + self.assertNotIn("BOARD_HAS_AUDIO", block) + + def test_epd_driver_has_explicit_z21_tricolor_path(self): + driver = read_text("src/epd_driver.cpp") + + self.assertIn("EPD_PANEL_42_Z21_BWR", driver) + self.assertIn("#include ", driver) + self.assertIn("#include ", driver) + self.assertIn("GxEPD2_420c_Z21", driver) + self.assertIn("writeZ21TricolorImage", driver) + self.assertIn("epdDisplay2bpp", driver) + + def test_z21_2bpp_mapping_keeps_white_and_red_from_swapping(self): + driver = read_text("src/epd_driver.cpp") + match = re.search( + r"#if defined\(EPD_PANEL_42_Z21_BWR\)\s*(static void z21SetPixel.*?)#endif", + driver, + flags=re.S, + ) + self.assertIsNotNone(match) + z21_block = match.group(1) + + self.assertRegex( + z21_block, + r"static\s+bool\s+z21IsRed2bppColor\s*\([^)]*\)\s*\{[^}]*color\s*==\s*0x03[^}]*color\s*==\s*0x02[^}]*\}", + ) + self.assertIn("z21IsRed2bppColor(color)", z21_block) + self.assertNotRegex(z21_block, r"else\s+if\s*\(\s*color\s*==\s*0x01\s*\)") + + def test_partition_table_matches_jcalendar_four_megabyte_flash(self): + partitions = read_text("partitions/jcalendar_4mb_ota.csv") + + expected_rows = [ + "nvs, data, nvs, 0x9000, 0x5000,", + "otadata, data, ota, 0xe000, 0x2000,", + "app0, app, ota_0, 0x10000, 0x1e0000,", + "app1, app, ota_1, 0x1f0000,0x1e0000,", + "spiffs, data, spiffs, 0x3d0000,0x20000,", + "coredump, data, coredump,0x3f0000,0x10000,", + ] + for row in expected_rows: + self.assertIn(row, partitions) + + def test_jcalendar_battery_voltage_uses_calibrated_lipo_mapping(self): + network_cpp = read_text("src/network.cpp") + match = re.search( + r"#if defined\(BOARD_PROFILE_JCALENDAR_ESP32\)(.*?)#elif defined\(BOARD_PROFILE_ESP32_C3_WROOM02\)", + network_cpp, + flags=re.S, + ) + self.assertIsNotNone(match) + block = match.group(1) + + self.assertIn("analogReadMilliVolts(PIN_BAT_ADC)", block) + self.assertIn("realBatteryVoltage", block) + self.assertRegex(block, r"\*\s*2\.0f") + self.assertIn("measuredLow = 2.95f", block) + self.assertIn("measuredHigh = 4.17f", block) + self.assertIn("targetHigh = 3.3f", block) + self.assertNotIn("[BAT]", network_cpp) + self.assertNotIn("report=%.2fV", network_cpp) + self.assertNotIn("avgRaw * (3.3f / 4095.0f) * 2.0f", block) + + def test_jcalendar_setup_does_not_force_debug_battery_read(self): + main_cpp = read_text("src/main.cpp") + self.assertNotRegex( + main_cpp, + r"#if defined\(BOARD_PROFILE_JCALENDAR_ESP32\)\s*readBatteryVoltage\(\);\s*#endif", + ) + + def test_config_button_short_press_requests_next_content(self): + main_cpp = read_text("src/main.cpp") + + self.assertIn("bool wantRefreshNext = false;", main_cpp) + self.assertIn("ctx.wantRefreshNext = true;", main_cpp) + self.assertIn("[BTN] Single click -> next content", main_cpp) + self.assertIn("if (ctx.wantRefreshNext)", main_cpp) + self.assertIn("triggerImmediateRefresh(true, true)", main_cpp) + self.assertNotIn("[BTN] Single click -> toggle live mode", main_cpp) + self.assertNotRegex( + main_cpp, + r"pressDuration[\s\S]*?ctx\.wantEnterLiveMode\s*=\s*true;", + ) + + def test_config_button_long_press_still_restarts_for_portal_entry(self): + main_cpp = read_text("src/main.cpp") + + self.assertIn("holdTime >= (unsigned long)CFG_BTN_HOLD_MS", main_cpp) + self.assertIn("ESP.restart();", main_cpp) + self.assertIn("Config button held -> portal", main_cpp) + + +if __name__ == "__main__": + unittest.main()