Writing a TuyaOpen BSP for a new board: A case study of the XTEink X4 Pro e-reader

Step-by-Step: Writing a TuyaOpen BSP for a New Board — Using the XTEink X4 Pro E-Reader as an Example

This article uses a real hardware device (the XTEink X4 Pro e-ink reader) as a case study to walk through the entire process of “writing a TuyaOpen Board Support Package (BSP) for a new board”: the design philosophy of separating BSP from the application, directory structure, API layering, driver implementation, verification methods, and practical pitfalls encountered along the way.


Background: TuyaOpen and “Adapting a New Board”

TuyaOpen is Tuya’s open-source cross-platform IoT SDK: the same application framework and build tools (tos.py) can run on Tuya’s T-series MCUs, ESP32, Raspberry Pi, Linux, and other platforms. Its cross-platform capability is achieved by isolating all “platform-specific parts” — chip differences are contained in platform/, and board differences are contained in boards/.

The repository already contains support code for over a dozen platforms and dozens of boards. The official release also provides tools/board_template/ as a starting template for new boards. In other words, “writing a BSP for new hardware” in TuyaOpen is a designed, replicable path, not something that needs to be reinvented from scratch every time. This is also why this article chose it as a teaching case: the process follows a clear methodology, and the pitfalls encountered are representative.

The case study board XTEink X4 Pro is a pocket-sized reader: 72g weight, 5.95mm thickness, with a touch screen and physical page-turning buttons. The front light supports dual-color temperature dimming (cool/warm). Specifications:

Component Specification
Main Controller ESP32-S3 (with PSRAM)
Screen 4.3" Black & White E-ink, 800×480
Input Touch screen (GT911) + Physical buttons
Front Light Dual-color temperature LED (cool/warm mix, adjustable brightness)
Storage microSD card slot
Battery 1100mAh lithium battery, direct ADC reading of VCELL

There are two practical reasons for choosing it as a case study: first, there is an active open-source firmware community (FreeInk / CrossPoint Reader) where you can reference verified driver implementations.


0. Design Philosophy: Separation of BSP and Application

Before writing any code, let’s clarify the layering principles that run through this article. TuyaOpen’s software structure consists of three layers:

Application (apps / example)
   │  Only calls tal_* / tkl_* abstract interfaces + board-level APIs
   ▼
TAL / TKL Abstraction Layer (src/tal_*, src/peripherals)
   │  Unified cross-platform peripheral interfaces
   ▼
BSP (boards/<platform>/<board>)      ← The part we are writing in this article
   │  Pin definitions, power-up sequence, chip initialization sequences
   ▼
Hardware

The responsibility of the BSP is singular: “translate” a specific board into a form that the SDK’s abstraction layer can understand. Pin numbers, power-up sequences, screen controller initialization sequences, battery voltage-to-percentage conversion curves — all knowledge that only applies to a specific board is contained within the BSP; application code is not allowed to contain any board-level details.

The only channel between the two layers is an API contract — in this article’s case, board_com_api.h. The application only sees this header file and cannot see any driver implementations.

Why insist on this separation? During the adaptation of the X4 Pro, it brought four visible benefits:

1. One board, multiple applications. Once the BSP is written, the dashboard application used for verification (lvgl_demo) and any future new applications will receive the same set of ready-made hardware interfaces. Developers writing new applications do not need to understand e-ink timing; knowing how to call board_x4pro_epd_display() is sufficient.

2. Independent evolution on both sides. A large amount of iteration occurs at the application layer in the later stages of adaptation: interactions like “long press to arm, release to power off,” transparency thresholds for popup masks, dashboard layout changes to 1×4 — these changes did not touch a single line of driver code. Conversely, the EPD driver evolved from hardcoding SSD1677 to chip detection + multi-backend support, with zero changes required in the application layer. The two change curves do not block each other.

3. Clear fault domains. When problems arise, you first look at which layer the phenomenon belongs to: if grayscale test patterns are garbled or refresh times out, the BSP is suspicious; if UI element positions are incorrect or interaction logic is weird, the application is suspicious. Layering makes “whose problem is this” largely unnecessary to debate.

4. Low barrier to reuse. The BSP delivers a directory plus an API header contract. Others拿到 this code do not need to understand the entire project to run their own applications on this board; conversely, existing open-source implementations in the community (FreeInk’s driver sequences, battery curves) can be directly fed into the BSP without polluting the application layer.

Correspondingly, there are three rules to adhere to (anti-patterns are easier to violate than best practices):

  • Applications do not touch the chip: Application code must not contain chip vendor header files or raw pin numbers. Exceptions (e.g., when TAL lacks SDMMC encapsulation, and SD mounting must use ESP-IDF interfaces) should be concentrated in a single driver file with commented registration.
  • BSP does not touch business logic: Driver files must not contain UI logic or product behavior (e.g., “decide to power off after a long press” belongs to the application layer; the BSP only reports button states).
  • Quantify all board-level facts: Pins, dimensions, and margins all go into board_config.h, so “what this board looks like” only requires checking one file.

The following steps are the concrete implementation of this philosophy.


Step 1: Building the Directory Skeleton

TuyaOpen’s convention is “one directory per board,” placed under boards/<chip_platform>/. The minimal skeleton requires only five files:

boards/ESP32/XTEINK_X4_PRO/
├── Kconfig           # Board registration + pin configuration options
├── CMakeLists.txt    # Compiles the directory into a library
├── board_config.h    # Pin numbers, dimension constants (the single "source of truth")
├── board_com_api.h   # Board-level API declarations exposed to the application
└── xteink_x4_pro.c   # Board entry: power-up sequence + peripheral registration

For each new type of peripheral added, add a pair of <board>_<peripheral>.c/.h and add a few declaration lines in board_com_api.h. The final complete structure for the X4 Pro:

├── xteink_x4_pro_epd.c         # E-ink display
├── xteink_x4_pro_touch.c       # GT911
├── xteink_x4_pro_buttons.c     # Physical buttons + Home key
├── xteink_x4_pro_battery.c     # Voltage, battery level, charging status
├── xteink_x4_pro_frontlight.c  # Front light dual-color temperature
├── xteink_x4_pro_sdcard.c      # SD card
└── example/
    └── lvgl_demo/              # Dashboard application for verifying the BSP (detailed in Step 5)

Kconfig is responsible for registering the board into the build system, while simultaneously declaring a few of the most commonly used pin configuration options:

config CHIP_CHOICE
    string
    default "esp32s3"

config BOARD_CHOICE
    string
    default "XTEINK_X4_PRO"

config BOARD_CONFIG
    bool
    default y
    select PLATFORM_FLASHSIZE_16M
    select ENABLE_EXT_RAM          # On-board PSRAM, don't forget to declare it

config UART_NUM0_TX_PIN
    int "UART_NUM0_TX_PIN"
    range 0 48
    default 43

Key points: The name of BOARD_CHOICE will run through the entire build process; board-specific hardware capabilities (here, 16MB Flash and PSRAM) are brought out using select, so the application side doesn’t need to configure them repeatedly.

CMakeLists.txt compiles the directory into a static library, with three core tasks: collect all .c files in this directory, add the directory itself as a public header file path, and append the library name to COMPONENT_LIBS:

aux_source_directory(${MODULE_PATH} LIB_SRCS)
add_library(${MODULE_NAME})
target_sources(${MODULE_NAME} PRIVATE ${LIB_SRCS})
target_include_directories(${MODULE_NAME} PUBLIC ${MODULE_PATH})
list(APPEND COMPONENT_LIBS ${MODULE_NAME})

A small tip: If individual drivers must use chip vendor header files (e.g., the X4 Pro’s SD card mounting requires ESP-IDF’s esp_vfs_fat because TAL lacks SDMMC encapsulation), explicitly list the required include paths in LIB_PRIVATE_INC and note in the file header that “this is one of the only two exceptions in the entire project.” Exceptions must be registered; otherwise, the abstraction layer becomes nominal only.


Step 2: Writing board_config.h — The Single Source of Truth

All pin numbers, screen dimensions, and buffer sizes are defined centrally in one header file; driver files are not allowed to contain raw numbers. Example for the X4 Pro (excerpt):

#define X4PRO_EPD_WIDTH   800
#define X4PRO_EPD_HEIGHT  480
#define X4PRO_PIN_EPD_RST   ...
#define X4PRO_PIN_EPD_BUSY  ...
#define X4PRO_VIEW_PAD_TOP  7   /* FreeInk ViewableInsets */

The value of this step becomes apparent during debugging: when changing batches, swapping components, or checking wiring, you only need to modify one file.


Step 3: Board Entry — The Power-Up Sequence is the First Lesson

xteink_x4_pro.c does only one thing: board_register_hardware(), pulling up all peripherals in the correct order.

The order is not arbitrary. The X4 Pro has two power rails: GPIO1 is the main power rail, and the GT911 enable pin (GPIO2, active low) depends on it. Main rail first, then peripheral enable; otherwise, the touch chip will never reset properly:

OPERATE_RET board_register_hardware(void)
{
    /* 1. Power rails: Main rail GPIO1 starts first, then pull low GPIO2 to enable touch */
    __rail_init(X4PRO_PIN_RAIL_MAIN, TUYA_GPIO_LEVEL_HIGH);
    __rail_init(X4PRO_PIN_TOUCH_EN,  TUYA_GPIO_LEVEL_LOW);

    /* 2. Initialize each peripheral according to dependencies */
    board_x4pro_epd_init();
    board_x4pro_touch_init();
    board_x4pro_frontlight_init();
    board_x4pro_buttons_init();
    board_x4pro_battery_init();
    return OPRT_OK;
}

Writing key points: The initialization sequence itself is board-level knowledge. Writing it in the entry function with comments explaining the reasons is easier to maintain than scattering it across various drivers.


Step 4: Peripheral Drivers — One File per Type, Same Pattern

Each peripheral driver follows the same template, using the battery as an example:

xteink_x4_pro_battery.c
├── Static internal state (sample values, filters, state machines)
├── board_x4pro_battery_init()     # Initialize hardware channel (ADC/I2C/SPI)
├── board_x4pro_battery_read(...)  # Read data
└── Optional: Event callback registration

Each function in the header file clearly defines input parameters, output parameters, and return value conventions. The application only faces this contract:

OPERATE_RET board_x4pro_battery_read(uint32_t *voltage_mv, uint8_t *percentage);
OPERATE_RET board_x4pro_battery_get_charge_state(X4PRO_CHARGE_STATE_E *state);
OPERATE_RET board_x4pro_battery_on_charge_state(X4PRO_CHARGE_STATE_CB cb);

Peripheral access always goes through TuyaOpen’s tkl_gpio / tkl_spi / tkl_i2c / tkl_adc wrappers, allowing the same driver to theoretically be reused across platforms. Below, we discuss what actually happened on the X4 Pro for each peripheral.

4.1 E-ink Display: Ask the Chip Who It Is Before Initialization

This was the most expensive lesson of the entire process. The first version of the driver was written according to the reference design’s SSD1677 controller. The boot self-test pattern was normal, but starting from the second refresh, it got stuck waiting for the BUSY pin every time:

[x4pro_epd] full refresh ... OPRT_TIMEOUT (-20)

Troubleshooting the power-up sequence and power rails yielded no results. The answer was finally found in the community firmware (FreeInk): new batches of the X4/X4 Pro switched to the UC8179/UC8279 controller — the initialization sequence is completely different, and even the BUSY polarity is inverted (UC81xx is high when idle, low when busy; SSD1677 is the opposite).

This solidified two takeaways for writing code:

Writing Method 1: Probe the chip ID before driver initialization. For the X4 Pro, before initializing SPI, use GPIO bits to knock out a half-duplex bus sequence — send 0x71 (FLG), read 0x70 (VER): only UC81xx responds to these two instructions, while SSD1677 remains silent, allowing for a binary decision:

/* xteink_x4_pro_epd.c (illustrative) */
static epd_ctrl_t __probe_controller(void)
{
    /* UC81xx responds to FLG/VER; SSD series lines float */
    uint8_t ver[3] = {0};
    __bus_read(UC_CMD_VER, ver, 3);
    if (!__bus_responded()) {
        return EPD_CTRL_SSD1677;
    }
    /* LUT_VER byte distinguishes multiple sub-models of UC8279 */
    switch (ver[1]) {
    case UC_LUT_VER_UC8279_A:
    case UC_LUT_VER_UC8279_B:
    case UC_LUT_VER_UC8279_C: return EPD_CTRL_UC8279;
    default:                  return EPD_CTRL_UC8179;
    }
}

The probe result is printed as a single line of log, visible at a glance when problems occur:

[x4pro_epd] probe: VER=0x98 -> UC8179

Writing Method 2: One backend per controller, initialization sequences copied byte-by-byte from verified open-source references. The PSR/TRES/BTST, KW dual-plane, PON/DRF, and local refresh paths for UC8179 were all ported byte-by-byte from the FreeInk driver — for timing-sensitive devices like e-ink displays, “reference implementations” are more reliable than data sheets.

4.2 Touch and Portrait Mode: Coordinate Transformation at the Boundary

The GT911 itself is easy to drive. The real problem is the portrait orientation: the panel coordinate system (800×480 landscape) differs from the “user-visible” coordinate system (480×800 portrait) by a 90° rotation.

The handling principle is transformations only occur in boundary callbacks, with intermediate layers living comfortably in their own coordinate systems:

  • Display side: Pixels written to the EPD frame buffer are rotated clockwise — set_pixel(py, (EPD_H - 1) - px), just that one line;
  • Touch side: Coordinates reported by GT911 undergo inverse rotation — px = 479 - y; py = x.

The two transformation chains are symmetric. If either is written incorrectly, the symptom is “the touch point and display position are separated by a diagonal,” which is easy to identify.

Side note: Not all boards need this layer. Only machines where “panel orientation ≠ installation orientation” have this step. But once present, be sure to define the mapping for both directions on day one, rather than leaving it until after the UI is written.

4.3 Battery: Supplement Missing Hardware with Signal Processing

The X4 Pro lacks a charging IC. “Is it charging?” can only be inferred from the VCELL trend. A small state machine is placed in the driver: sampling every 2 seconds, passing through an EMA filter with α=1/8, and then —

IDLE ──3 consecutive sample points, voltage rise ≥10mV──▶ CHARGING
CHARGING ──Voltage ≥4.19V and slope <3mV (plateau phase)──▶ FULL
Any state ──Voltage drops below anchor point──▶ IDLE

The two numbers were tuned through practical testing. The methodology here is more important than the values:

  • Filtering is mandatory: Voltage drops by ~100mV during a full e-ink refresh; raw sampling would cause the state machine to glitch;
  • Confirmation requires multiple consecutive samples (here, 3-point latching): A single glitch does not constitute a state transition;
  • Leave a cross-validation channel: Raw ADC readings are always logged in debug logs, displayed side-by-side with the percentage converted from the curve. Whoever errs first is visible at a glance.

Battery percentage directly ported the FreeInk lithium battery discharge curve conversion (with hysteresis). Community reference implementations are almost always worth copying for “dirty work” like sensor calibration.

4.4 Front Light and Buttons: Watch Out for Dimensions and Return Value Temperament

These two peripherals are the simplest, but each left a lesson:

  • Dimension conversion into the driver: The upper layer gives a percentage of 0~100, while the bottom-layer LEDC is a 12-bit duty cycle window. Without pre-scaling, dimming in the low brightness range is barely visible. The driver also provides reasonable default values (50% brightness, slightly cool color temperature).
  • Tolerate the adapter’s “soft failures”: Some platform adapters return OPRT_NOT_SUPPORTED for unsupported operations rather than hard errors. The driver layer should tolerate this rather than treating it as a failure to retry.

4.5 Discipline Running Through the Whole Process: Don’t Mess Up Log Macros

An incident where all button and touch logs disappeared was finally traced to the format of the log macro:

PR_NOTICE(TAG, "key pressed");    // ✗ Message silently swallowed
PR_NOTICE("[x4pro] key pressed"); // ✓ Format string is the first argument

TuyaOpen’s PR_* macros take the format string as the first argument. Writing it as a two-argument form doesn’t error, it just stays silent. Before debugging any driver, print one simple event log first to confirm the link is working. This habit pays for itself.


Step 5: Verification — Writing a “Health Check Application” for the BSP

A BSP is not considered “done” just by being written; it needs a minimal application that can touch all its interfaces. The X4 Pro’s health check application is example/lvgl_demo, a vertical dashboard:

Its three design aspects are worth copying:

  1. Boot with a test image first. A 16-level grayscale Bayer dithering image (dithering simulates grayscale on black & white screens): which cell is off, which margin is wrong, whether BUSY recovers — one image explains everything. This image later became a routine health check item after each round of driver changes.
  2. Each peripheral occupies one grid for real-time refresh. Whatever the driver returns, the screen displays. No guessing which环节 is problematic when issues arise.
  3. Event-driven logging. Only events like button presses, touch inputs, and charging state switches trigger NOTICE logs; periodic refresh durations are downgraded to DEBUG — the serial port always contains only what is worth seeing.

Additionally, two details磨 out in the health check application, related to the temperament of black & white screens, are worth noting in any e-ink BSP notes:

  • Transparency threshold for 1-bit screens: LVGL semi-transparent masks are quantized. Opacity below approximately 46% is judged as white (equivalent to none). Popup layer masks must use at least 70% black;
  • Refresh a meaningful frame before power-off: E-ink retains the last frame after power loss. In the shutdown sequence, first refresh a test image to clear residual shadows before cutting power.

Image Refresh Test


Step 6: Review Checklist

After writing a board, you can self-check against this list:

  • [ ] Are all pin and dimension constants collected into board_config.h, with no raw numbers in drivers?
  • [ ] Were key chips probed for ID, rather than assuming batch consistency?
  • [ ] Is the power-up sequence written in the entry function, with dependencies noted?
  • [ ] Does each peripheral only expose to the upper layer via board_com_api.h?
  • [ ] Are all places using vendor header files registered as “exceptions”?
  • [ ] Do sensor readings have filtering, multiple confirmations, and cross-validation?
  • [ ] Is there a health check application that can touch all interfaces + a boot test image?
  • [ ] Was the log link confirmed before debugging (PR_* format)?

Reviewing the entire adaptation process, several practical experiences that proved useful:

  1. Probing is better than assuming. Component swapping is normal in mass production. The cost of reading a chip ID once is one line of code.
  2. Coordinate transformations at the boundary. Rotation only appears in two callbacks, keeping UI code clean.
  3. Supplement missing hardware with software. No charging IC? Use a state machine. No grayscale? Use dithering — most “missing” features in e-ink devices have software solutions.
  4. Use community firmware as a reference. FreeInk/CrossPoint helped us confirm chip models, calibrate battery curves, and provided byte-by-byte initialization sequences. For adapting niche hardware, the open-source community is the best data sheet.
  5. Test images before features. “Rustic methods” like grayscale images and real-time status panels are far more efficient than breakpoint debugging for devices where refresh takes seconds.

Appendix: Code Locations for This Case

Content Path
Board Support Package boards/ESP32/XTEINK_X4_PRO/
Board Entry (Power-up Sequence) boards/ESP32/XTEINK_X4_PRO/xteink_x4_pro.c
EPD Driver (with Chip Detection) boards/ESP32/XTEINK_X4_PRO/xteink_x4_pro_epd.c
Battery and Charging Status Estimation boards/ESP32/XTEINK_X4_PRO/xteink_x4_pro_battery.c
Front Light Dual-Color Temperature boards/ESP32/XTEINK_X4_PRO/xteink_x4_pro_frontlight.c
Health Check Application (LVGL Dashboard) boards/ESP32/XTEINK_X4_PRO/example/lvgl_demo/
CrossPoint Open-Source Reader (Reference Implementation) crosspoint-reader/

BSP Code Location:

1 Like