Example for using SPI TFT display with xiao nRF54L15?

I’ve been trying to work out a build for using the xiao nRF54L15 with the piTFT display (e.g. Pinouts | Adafruit Mini PiTFT - Color TFT Add-ons for Raspberry Pi | Adafruit Learning System ). I’m not having much luck yet. I saw one spi example @Toastee0 in your git ( Seeed-Xiao-nRF54L15/led_displays/led_strip/boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay at main · Toastee0/Seeed-Xiao-nRF54L15 · GitHub ) but have also been trying to see if MIPI_DBI_MODE_SPI_4WIRE would work.

Does anyone have an example of using SPI for a TFT display on xiao nRF54L15?

Hi there,

Yes, SO you can drive SPI TFT displays from the XIAO nRF54L15 using Zephyr’s display subsystem. The recommended approach is to use the MIPI-DBI SPI wrapper (zephyr,mipi-dbi-spi) together with the appropriate display controller driver. I’m trying the round display.(GC9A01)

For most Adafruit Mini PiTFT boards, the controller is ST7789, so the Zephyr compatible should be:

sitronix,st7789v

The SPI interface mode should be 4-wire DBI, which Zephyr supports through:

MIPI_DBI_MODE_SPI_4WIRE

Below is a minimal devicetree overlay example that should work as a starting point. :+1:

Devicetree overlay

boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay

#include <zephyr/dt-bindings/mipi_dbi/mipi_dbi.h>

/ {
    chosen {
        zephyr,display = &st7789;
    };
};

&spi20 {
    status = "okay";
    cs-gpios = <&gpio0 2 GPIO_ACTIVE_LOW>;

    mipi_dbi: mipi_dbi@0 {
        compatible = "zephyr,mipi-dbi-spi";
        #address-cells = <1>;
        #size-cells = <0>;
        spi-dev = <&spi20>;
        dc-gpios = <&gpio0 3 GPIO_ACTIVE_HIGH>;
        reset-gpios = <&gpio0 4 GPIO_ACTIVE_LOW>;
        write-only;

        st7789: st7789v@0 {
            compatible = "sitronix,st7789v";
            reg = <0>;

            mipi-max-frequency = <32000000>;
            mipi-mode = <MIPI_DBI_MODE_SPI_4WIRE>;

            width = <240>;
            height = <240>;

            pixel-format = <PANEL_PIXEL_FORMAT_RGB_565>;
            status = "okay";
        };
    };
};

prj.conf

CONFIG_SPI=y
CONFIG_GPIO=y
CONFIG_DISPLAY=y
CONFIG_MIPI_DBI=y
CONFIG_LOG=y
CONFIG_MAIN_STACK_SIZE=2048

Minimal test application Smoke Test :grin:

#include <zephyr/device.h>
#include <zephyr/drivers/display.h>
#include <zephyr/kernel.h>
#include <string.h>

int main(void)
{
    const struct device *display = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));

    if (!device_is_ready(display)) {
        return 0;
    }

    struct display_capabilities caps;
    display_get_capabilities(display, &caps);

    static uint16_t line[240];
    memset(line, 0xF800, sizeof(line));   // red in RGB565

    struct display_buffer_descriptor desc = {
        .buf_size = sizeof(line),
        .width = 240,
        .height = 1,
        .pitch = 240,
    };

    display_blanking_off(display);

    for (int y = 0; y < 240; y++) {
        display_write(display, 0, y, &desc, line);
    }

    while (1) {
        k_sleep(K_SECONDS(1));
    }
}

Notes

• The exact width/height and offsets depend on the PiTFT variant.
• The 1.14" PiTFT is typically 135×240 and requires offsets.
• The 1.3" PiTFT is usually 240×240.

Also note that some Nordic community examples mention SPI00 vs SPI20 on the nRF54L15 — SPI20 tends to be the more flexible SPI instance for general peripherals.

The working pattern for SPI TFT displays on XIAO nRF54L15 + NCS is:

zephyr,mipi-dbi-spi
        ↓
panel driver (sitronix,st7789v / st7735r / gc9x01x)
        ↓
MIPI_DBI_MODE_SPI_4WIRE

Once the panel is working, you can easily layer LVGL on top for UI development.

HTH

GL :slight_smile: PJ :v:

for the Seeed Studio Round Display for XIAO, there is a much cleaner path than trying to hand-roll raw SPI first.

The Round Display is already supported in Zephyr as the seeed_xiao_round_display shield, and the display side uses the GC9A01/GC9X01X family over SPI, with the touch controller on I2C. That means on the XIAO nRF54L15 the most natural first step is to build a Zephyr sample with the shield enabled and let the display stack do the heavy lifting

west build -p always -b xiao_nrf54l15/nrf54l15/cpuapp samples/modules/lvgl/demos -- \
  -DSHIELD=seeed_xiao_round_display \
  -DCONFIG_LV_Z_DEMO_WIDGETS=y

:grin: :+1:

1 Like
1 Like

Thanks @PJ_Glasso, this was helpful.

I did end up needing a bit more in my prj.conf for my spi to even compile, not sure if that’s due to a later version of nRF connect in VScode? Here was what I have working with the DIYmall TFT 240x240 displays from amazon (similar to adafruit, no reset line required):partying_face:


#
# Copyright (c) 2024 Nordic Semiconductor ASA
#
# SPDX-License-Identifier: LicenseRef-Nordic-5-Clause
#

# Minimal config for Xiao nRF54L15 (no external memory)
CONFIG_GPIO=y
CONFIG_REBOOT=y

# Force SoftDevice Controller only (disable Zephyr controller)
CONFIG_BT_LL_SW_SPLIT=n

CONFIG_BT=y
CONFIG_BT_SMP=y
CONFIG_BT_CENTRAL=y
CONFIG_BT_MAX_CONN=1
CONFIG_BT_BONDABLE=n

CONFIG_BT_GATT_CLIENT=y
CONFIG_BT_GATT_DYNAMIC_DB=y

CONFIG_BT_CHANNEL_SOUNDING=y
CONFIG_BT_RAS=y
CONFIG_BT_RAS_RREQ=y

CONFIG_BT_SCAN=y
CONFIG_BT_SCAN_FILTER_ENABLE=y
CONFIG_BT_SCAN_UUID_CNT=1

# The Ranging Profile recommends a MTU of at least 247 octets.
CONFIG_BT_L2CAP_TX_MTU=498
CONFIG_BT_BUF_ACL_TX_SIZE=502
CONFIG_BT_BUF_ACL_RX_SIZE=502
CONFIG_BT_ATT_PREPARE_COUNT=3
CONFIG_BT_CTLR_DATA_LENGTH_MAX=251

# Single antenna configuration for Xiao nRF54L15
CONFIG_BT_RAS_MAX_ANTENNA_PATHS=1
CONFIG_BT_CTLR_SDC_CS_ROLE_INITIATOR_ONLY=y

# Disabling the CS Test command reduces flash usage
CONFIG_BT_CTLR_CHANNEL_SOUNDING_TEST=n

# This allows CS and ACL to use different PHYs
CONFIG_BT_TRANSMIT_POWER_CONTROL=y

# This improves the performance of floating-point operations
CONFIG_FPU=y
CONFIG_FPU_SHARING=y

CONFIG_CBPRINTF_FP_SUPPORT=y

CONFIG_BT_CS_DE=y
CONFIG_BT_CS_DE_512_NFFT=y

# Console and logging
CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y

####### ####### ####### ####### ####### ####### ####### #######

# SPI config
CONFIG_SPI=y
CONFIG_SPI_NRFX=y
CONFIG_SPI_NRFX_RAM_BUFFER_SIZE=256
CONFIG_MIPI_DBI=y
CONFIG_MIPI_DBI_SPI=y
CONFIG_SPI_ASYNC=y

# DISPLAY config
CONFIG_ST7789V=y
CONFIG_ST7789V_RGB565=y
CONFIG_DISPLAY=y

# LVGL config
CONFIG_LVGL=y

CONFIG_LV_COLOR_DEPTH_16=y
CONFIG_LV_COLOR_16_SWAP=n
CONFIG_LV_FONT_MONTSERRAT_28=y
CONFIG_LV_USE_LABEL=y
CONFIG_LV_FONT_DEFAULT_MONTSERRAT_28=y

CONFIG_HEAP_MEM_POOL_SIZE=65536
CONFIG_LV_Z_MEM_POOL_SIZE=65536
CONFIG_MAIN_STACK_SIZE=8192

####### ####### ####### ####### ####### ####### ####### #######

CONFIG_LV_Z_MEM_POOL_SIZE=16384
CONFIG_LV_Z_BUFFER_ALLOC_DYNAMIC=y

CONFIG_IDLE_STACK_SIZE=1024
CONFIG_MAIN_STACK_SIZE=4096
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096
CONFIG_MPSL_WORK_STACK_SIZE=2048

# Refine LVGL memory usage
#CONFIG_LV_Z_MEM_POOL_SIZE=8192
CONFIG_LV_Z_VDB_SIZE=10

####### ####### ####### ####### ####### ####### ####### #######
# Fixed the random color display: shrink the VDB to bypass DMA limits
CONFIG_LV_Z_VDB_SIZE=1
####### ####### ####### ####### ####### ####### ####### #######

## logging
CONFIG_LOG=y
CONFIG_LOG_MODE_IMMEDIATE=y
CONFIG_LOG_DEFAULT_LEVEL=3

CONFIG_LV_USE_LOG=y
CONFIG_LV_LOG_LEVEL_INFO=y
CONFIG_DISPLAY_LOG_LEVEL_ERR=y
CONFIG_SPI_LOG_LEVEL_ERR=y
CONFIG_MIPI_DBI_LOG_LEVEL_ERR=y

And here is my `boards/xiao_nrf54l15_nrf54l15_cpuapp.overlay`

#include <zephyr/dt-bindings/mipi_dbi/mipi_dbi.h>

/ {
    chosen {
        zephyr,display = &st7789_display;
        zephyr,sram = &cpuapp_sram;
    };

    /* The virtual MIPI wrapper MUST live at the root */
    mipi_dbi: mipi_dbi {
        compatible = "zephyr,mipi-dbi-spi";
        
        /* The Fix: Point directly to the real hardware bus! */
        spi-dev = <&xiao_spi>;
        
        dc-gpios = <&gpio1 4 GPIO_ACTIVE_HIGH>;
        write-only;
        #address-cells = <1>;
        #size-cells = <0>;

        st7789_display: st7789@0 {
            compatible = "sitronix,st7789v";
            
            /* This reg value tells it to use index 0 of the cs-gpios array on &xiao_spi */
            reg = <0>;
            
            mipi-max-frequency = <32000000>;
            mipi-mode = "MIPI_DBI_MODE_SPI_4WIRE";
            
            width = <240>;
            height = <240>;
            x-offset = <0>;
            y-offset = <0>;
            
            vcom = <0x19>;
            gctrl = <0x35>;
            vrhs = <0x12>;
            vdvs = <0x20>;
            mdac = <0x00>;
            gamma = <0x01>;
            colmod = <0x05>;
            lcm = <0x2c>;
            porch-param = [0c 0c 00 33 33];
            cmd2en-param = [5a 69 02 01];
            pwctrl1-param = [a4 a1];
            pvgam-param = [D0 04 0D 11 13 2B 3F 54 4C 18 0D 0B 1F 23];
            nvgam-param = [D0 04 0C 11 13 2C 3F 44 51 2F 1F 1F 20 23];
            ram-param = [00 F8];
            rgb-param = [cd 08 14];
        };
    };
};

/* The custom pins to ensure the Nordic SPI driver is compiled */
&pinctrl {
    my_spi_default: my_spi_default {
        group1 {
            psels = <NRF_PSEL(SPIM_SCK, 2, 1)>,
                    <NRF_PSEL(SPIM_MOSI, 2, 2)>;
        };
    };
    my_spi_sleep: my_spi_sleep {
        group1 {
            psels = <NRF_PSEL(SPIM_SCK, 2, 1)>,
                    <NRF_PSEL(SPIM_MOSI, 2, 2)>;
            low-power-enable;
        };
    };
};

/* The hardware SPI bus */
&xiao_spi {
    status = "okay";
    pinctrl-0 = <&my_spi_default>;
    pinctrl-1 = <&my_spi_sleep>;
    pinctrl-names = "default", "sleep";
    cs-gpios = <&gpio1 5 GPIO_ACTIVE_LOW>;
};

&gpio0 { status = "okay"; };
&gpio1 { status = "okay"; };
&gpio2 { status = "okay"; };

/* --- Nordic SRAM and Power Configuration --- */
&cpuapp_sram { reg = <0x20000000 0x40000>; };
&rfsw_ctl { regulator-boot-on; };
&rfsw_pwr { regulator-boot-on; };
&pdm20 { status = "disabled"; };
&adc { status = "disabled"; };
&comp { status = "disabled"; };

I have this merged together with the ble channel sounding example for the initiator and displaying the distance. Here are the key main() parts:

int main(void)
{ 
  int err;
    
  k_msleep(500);
  const struct device *display_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_display));
          
    if (!device_is_ready(display_dev)) {
        printk("Error: Display device is not ready.\n");
        return 0;
    } else {
        printk("===========  Display ready  ============\n");
    }   
          
    display_blanking_off(display_dev);
    lv_obj_set_style_bg_color(lv_scr_act(), lv_palette_main(LV_PALETTE_BLUE), 0);
    lv_obj_set_style_bg_opa(lv_scr_act(), LV_OPA_COVER, 0);
          
    lv_obj_t *hello_label = lv_label_create(lv_scr_act());
    lv_label_set_text(hello_label, "Hello!");
    lv_obj_align(hello_label, LV_ALIGN_CENTER, 0, 0);
    lv_timer_handler();
          

/* ... */

  while (true) {
    if (k_sem_take(&sem_distance_estimate_updated, K_MSEC(10)) == 0) {
      if (buffer_num_valid != 0) {
        for (uint8_t ap = 0; ap < MAX_AP; ap++) {
          cs_de_dist_estimates_t distance_on_ap = get_distance(ap);
          uint32_t timestamp = k_uptime_get_32();

          /* Calculate weighted average, ignoring zero/invalid values */
          float weighted_sum = 0.0f;
          float weight_sum = 0.0f;

          if (distance_on_ap.ifft > 0.01f) {
            weighted_sum += distance_on_ap.ifft * IFFT_WEIGHT;
            weight_sum += IFFT_WEIGHT;
          }
          if (distance_on_ap.phase_slope > 0.01f) {
            weighted_sum += distance_on_ap.phase_slope * PHASE_WEIGHT;
            weight_sum += PHASE_WEIGHT;
          }
          if (distance_on_ap.rtt > 0.01f) {
            weighted_sum += distance_on_ap.rtt * RTT_WEIGHT;
            weight_sum += RTT_WEIGHT;
          }

          float best_estimate = 0.0f;
          if (weight_sum > 0.01f) {
            best_estimate = weighted_sum / weight_sum;
          }

          snprintf(buf, sizeof(buf), "IFFT:%.2f\nPBR:%.2f", (double)distance_on_ap.ifft, (double)distance_on_ap.phase_slope);
          lv_label_set_text(hello_label, buf);
        }
      }
      lv_timer_handler();
      k_msleep(30);
    }
  }

/* ... */

Hi there,

EggsaLent :grin: Glad you got it working, :+1: Yes, probably needed some fine tuning for the SDK diff’s but it got you close enough to get over the bump. Good staying with it and Thanks for the contribution, I am certain others will find it very helpful as well.
Great job.

GL :slight_smile: PJ :v:

1 Like