Skip to content

Driving an RGB dot matrix over I2C

  1. Send commands to the DFR0522 8x16 RGB matrix at address 0x10 on the 3.3 V bus
  2. Combine potentiometer input with matrix and screen output in one program

What the QWA309 base board gives you to practise with

Section titled “What the QWA309 base board gives you to practise with”

The QWA309 base board of the TESAIoT Dev Kit gives you real hardware to practise with: push buttons, four potentiometers, a CAN transceiver and a header for external devices. This lesson uses Developer Hub exercises written specifically for this board. All three exercises in this lesson drive the same RGB dot-matrix display (a DFRobot DFR0522) through a shared low-level I2C driver in rgb_panel.c, but differ in how they trigger it: direct button commands, an automatic animation, and colour mixing from potentiometers.

What the DFR0522 is, and what commands it accepts

Section titled “What the DFR0522 is, and what commands it accepts”

The DFR0522 is a 16×8-pixel RGB dot-matrix display (RGB_PANEL_WIDTH = 16, RGB_PANEL_HEIGHT = 8), driven over I2C at address 0x10 (RGB_PANEL_I2C_ADDRESS), supporting 8 colours (RGB_PANEL_COLOR_OFF through RGB_PANEL_COLOR_WHITE, values 0–7). Every command is assembled into one frame: the first byte is a fixed command register 0x02, followed by a function byte that says whether to clear (0x01), fill the whole panel (0x09) or light a single pixel (0x08), then the colour value and the x/y coordinates. The whole frame is padded to a fixed RGB_PANEL_TX_SIZE = 51 bytes (a 50-byte payload plus the 1-byte command register), so every command sends the same length regardless of what it does.

Writing a byte-level I2C driver by hand, with no ready-made wrapper

Section titled “Writing a byte-level I2C driver by hand, with no ready-made wrapper”

rgb_panel_write() in rgb_panel.c drives I2C with three of the lowest-level PDL calls: Cy_SCB_I2C_MasterSendStart() sends the address with a write transfer, a loop of Cy_SCB_I2C_MasterWriteByte() then sends each byte until all RGB_PANEL_TX_SIZE bytes are out, and Cy_SCB_I2C_MasterSendStop() always closes the transaction, whether or not the byte writes succeeded (so the bus returns to a clean state instead of hanging). Every byte has a RGB_PANEL_BYTE_TIMEOUT_MS = 5 ms timeout. All three exercises call this same driver through three public functions: rgb_panel_clear(), rgb_panel_fill() and rgb_panel_pixel().

Sharing the display/touch I2C bus, not the master’s sensor I2C

Section titled “Sharing the display/touch I2C bus, not the master’s sensor I2C”

All three exercises drive the DFR0522 through DISPLAY_I2C_CONTROLLER_HW and the disp_touch_i2c_controller_context context — the same SCB I2C instance used for the display and touchscreen, not the sensor I2C the master template opens (see lesson 3.1). The reason is that the sensor I2C sits on the 1.8 V domain, while the DFR0522 shares the 3.3 V bus with the display/touch. Wiring a device to the wrong voltage domain can fail to communicate, or damage the pins. It also matters that the framework already initialises this bus before example_main() ever runs, so the code can reuse the existing context immediately without re-initialising it (re-initialising it could break the touchscreen).

Checking a device is really there before trusting the result

Section titled “Checking a device is really there before trusting the result”

The “Check 0x10” button in DFR0522 RGB Dot Matrix calls check_panel_device(), which sends only a START with the address, then an immediate STOP — no data at all — to test whether any device ACKs that address. The result is a cy_en_scb_i2c_status_t value, translated into readable text by i2c_status_to_text(): for example “ADDRESS NACK” (nothing answered at all), “DATA NACK” (a device answered the address but rejected the data), “TIMEOUT”, or “ARBITRATION LOST”. This distinction matters: “ADDRESS NACK” points to a wiring, power or wrong-address problem, whereas an error that appears after the address is acknowledged points to a command-level or timing problem instead.

Mixing R/G/B from potentiometers by packing bits (Pot → RGB Mixer)

Section titled “Mixing R/G/B from potentiometers by packing bits (Pot → RGB Mixer)”

Pot → RGB Mixer reads the first three potentiometers (channel indices 0–2 on P15.4–P15.6) every MIX_REFRESH_MS = 120 ms. Each channel turns its own bit on if the raw value is ≥ MIX_THRESHOLD = 2048 (about 50%): bits |= (1U << i), with i = 0 for R, i = 1 for G, i = 2 for B. The three bits together cast directly to rgb_panel_color_t, because the DFR0522’s colour enum packs its bits in the same order (for example R and B both on gives bits 0b101 = 5 = RGB_PANEL_COLOR_PURPLE). The code only rewrites the panel when the colour changes from the previous tick (if (color != s_last_color)), so it does not resend a 51-byte frame every 120 ms on a bus shared with the touchscreen when nothing needs to change. s_last_color starts at RGB_PANEL_COLOR_WHITE to force the very first write.

Animating with a frame-counting state machine (RGB Matrix FX)

Section titled “Animating with a frame-counting state machine (RGB Matrix FX)”

RGB Matrix FX cycles through 3 effects automatically with an lv_timer at FX_PERIOD_MS = 140 ms. The variable s_frame counts from 0 up to FX_FRAMES_PER_EFFECT - 1 (24 frames) per effect, then s_effect advances to the next one modulo 3. fx_step() picks the behaviour by s_effect: effect 0 (Colour Cycle) calls rgb_panel_fill() on the whole panel, cycling through the s_cycle[7] colour array by s_frame % 7; effect 1 (Pixel Sweep) clears the panel then lights a single pixel at x = (s_frame * 2) % FX_COLS, y = (s_frame / 2) % FX_ROWS; effect 2 (Row Wipe) fills the whole panel with a colour that changes more slowly (s_frame / 4). Every effect calls the same rgb_panel driver as the other two exercises.

The QWA309 exercise set on the Developer Hub (pinned to commit e5c7722) runs only on the TESAIoT Dev Kit, because it uses hardware on the base board.

  • QWA309 — DFR0522 RGB Dot Matrix — drives a DFRobot DFR0522 8x16 RGB matrix (I2C address 0x10) on the 3.3 V bus alongside the display, showing clear/fill/pixel/pattern actions through the LVGL UI README · code · Developer Hub
  • QWA309 — RGB Matrix FX — animated effects on the DFR0522 8x16 matrix (colour cycle / pixel sweep / row wipe), auto-cycling with status shown on the LCD README · code · Developer Hub
  • QWA309 — Pot → RGB Mixer — three potentiometers act as the R/G/B channels (over 50% turns that colour on), mixed into one of the DFR0522 matrix’s 8 colours and shown on the LCD — combining the SAR pots with RGB over I2C README · code · Developer Hub

The excerpts below are copied from the actual files at the same commit (Apache-2.0, tesaiot/developer-hub).

rgb_panel.c — the byte-level I2C driver all three exercises share:

status = Cy_SCB_I2C_MasterSendStart(base,
RGB_PANEL_I2C_ADDRESS,
CY_SCB_I2C_WRITE_XFER,
RGB_PANEL_BYTE_TIMEOUT_MS,
context);
if (status == CY_SCB_I2C_SUCCESS)
{
for (uint32_t i = 0U; i < RGB_PANEL_TX_SIZE; i++)
{
status = Cy_SCB_I2C_MasterWriteByte(base,
tx_buffer[i],
RGB_PANEL_BYTE_TIMEOUT_MS,
context);
if (status != CY_SCB_I2C_SUCCESS) { break; }
}
}
stop_status = Cy_SCB_I2C_MasterSendStop(base,
RGB_PANEL_BYTE_TIMEOUT_MS,
context);

rgb_matrix_ui.c — checks whether a device ACKs address 0x10 before any real command is sent:

static cy_en_scb_i2c_status_t check_panel_device(void)
{
cy_en_scb_i2c_status_t status;
status = Cy_SCB_I2C_MasterSendStart(DISPLAY_I2C_CONTROLLER_HW,
RGB_PANEL_I2C_ADDRESS,
CY_SCB_I2C_WRITE_XFER,
DEVICE_CHECK_TIMEOUT_MS,
&disp_touch_i2c_controller_context);
(void)Cy_SCB_I2C_MasterSendStop(DISPLAY_I2C_CONTROLLER_HW,
DEVICE_CHECK_TIMEOUT_MS,
&disp_touch_i2c_controller_context);
return status;
}

pot_rgb_mixer_ui.c — packs R/G/B bits from the threshold and writes the panel only when the colour changes:

uint8_t bits = 0U;
for (uint8_t i = 0U; i < 3U; i++) {
uint16_t raw = mix_read(s_ch[i].ch);
/* ... update bar/label ... */
if (raw >= MIX_THRESHOLD) { bits |= (uint8_t)(1U << i); }
}
/* bits: b0=R b1=G b2=B -> DFR0522 colour enum is the same RGB packing. */
rgb_panel_color_t color = (rgb_panel_color_t)bits;
if (color != s_last_color) {
s_last_color = color;
(void)rgb_panel_fill(DISPLAY_I2C_CONTROLLER_HW,
&disp_touch_i2c_controller_context, color);
}

rgb_fx_ui.c — picks the effect by s_effect and draws one frame:

switch (s_effect) {
case 0U: /* colour cycle — one fill per frame */
(void)rgb_panel_fill(FX_HW, FX_CTX, s_cycle[s_frame % 7U]);
break;
case 1U: { /* pixel sweep — clear then light one pixel */
(void)rgb_panel_clear(FX_HW, FX_CTX);
uint8_t x = (uint8_t)((s_frame * 2U) % FX_COLS);
uint8_t y = (uint8_t)((s_frame / 2U) % FX_ROWS);
(void)rgb_panel_pixel(FX_HW, FX_CTX, x, y, s_cycle[s_frame % 7U]);
break;
}
  • Wiring the DFR0522 to the master’s sensor I2C instead of the display/touch 3.3 V bus — the sensor I2C sits on the 1.8 V domain; wiring the wrong voltage domain can fail to communicate or damage the pins. Always use DISPLAY_I2C_CONTROLLER_HW.
  • Treating “ADDRESS NACK” and a later error as the same problem — ADDRESS NACK means nothing answered at all (check the cable, power, address); an error after the address is acknowledged is a command-level or timing problem, a different cause entirely.
  • Rewriting the panel on every poll tick without checking whether the colour changed — Pot → RGB Mixer only writes when the colour actually changes, to spare the bus it shares with the touchscreen. If the first computed colour happens to match the s_last_color starting value (WHITE), the panel stays unwritten until the colour genuinely changes.
  • Forgetting that rgb_panel_fill()/rgb_panel_pixel() always validate their parameters first and return CY_SCB_I2C_BAD_PARAM — an out-of-range colour or coordinate is caught inside the driver itself, before anything wrong ever reaches the bus.
Terminal window
# In the master template folder (see lesson 1.1)
# 1) Delete the old episode's files in proj_cm55/apps/
# 2) Copy all of this episode's files into proj_cm55/apps/
make build
make program # flash through KitProg3
  1. Guess before you change anything: pick one value the example’s README explains in the How section, and write down what you expect to change on the screen or in the log.
  2. Change and run: build + flash, then compare against your guess. If it does not match, find which part you misunderstood.
  3. Extend: add one thing the example does not yet have, and keep a photo or video in your portfolio.
  • What tells two devices on the same I2C bus apart?
  • If the matrix does not respond, what should you check first (cable, power, address)?

The answers are in the example’s README and in the code. If you cannot answer one, go back and read the Why / What / How section again.

  • All TESAIoT Dev Kit exercises · commit e5c7722
  • The code belongs to the Developer Hub and is referenced by link, not copied into this repository

Review questions

Answer on your own first, then open the answer.

  1. The DFR0522 shares an I2C bus with the display's touch controller. What keeps the two devices apart? (Objective 1)

    1. สาย chip-select แยกของแต่ละตัว
    2. ความเร็ว clock ที่ต่างกัน
    3. address 7 บิตที่ส่งหลัง START เฉพาะอุปกรณ์ที่ address ตรงกัน (0x10 สำหรับ matrix) จึงตอบ ACK และรับข้อมูล
    4. ลำดับการเสียบสาย
    Show answer

    Answer: C. address 7 บิตที่ส่งหลัง START เฉพาะอุปกรณ์ที่ address ตรงกัน (0x10 สำหรับ matrix) จึงตอบ ACK และรับข้อมูล

    rgb_panel_write() เรียก Cy_SCB_I2C_MasterSendStart() ด้วย RGB_PANEL_I2C_ADDRESS (0x10) อุปกรณ์อื่นบน bus เห็นว่า address ไม่ใช่ของตนจึงไม่ตอบ สาย chip-select เป็นวิธีของ SPI ไม่ใช่ I2C และอุปกรณ์บน bus เดียวกันต้องมี address ไม่ซ้ำกัน

  2. ‘Check 0x10’ reports ‘ADDRESS NACK’. What should you check first? (Objective 1)

    1. สาย SDA/SCL ไฟเลี้ยงของ matrix และ address ของโมดูลว่าเป็น 0x10 จริง เพราะไม่มีอุปกรณ์ใดตอบรับ address นี้เลย
    2. ค่า function byte ของคำสั่ง fill
    3. สีที่เลือกอยู่นอกช่วง
    4. ขนาด tx buffer 51 ไบต์
    Show answer

    Answer: A. สาย SDA/SCL ไฟเลี้ยงของ matrix และ address ของโมดูลว่าเป็น 0x10 จริง เพราะไม่มีอุปกรณ์ใดตอบรับ address นี้เลย

    check_panel_device() ส่งแค่ START พร้อม address แล้ว STOP ถ้าไม่มี ACK แปลว่าปัญหาอยู่ก่อนถึงเนื้อหาคำสั่ง คือสาย ไฟ หรือ address ข้อผิดพลาดของรูปแบบคำสั่งจะเกิดหลังอุปกรณ์ตอบ address แล้ว และสีที่ผิดช่วงถูกดักเป็น BAD PARAMETER ก่อนส่งด้วยซ้ำ

  3. Why is the DFR0522 driven on the display/touch I2C bus (DISPLAY_I2C_CONTROLLER_HW) rather than the master's sensor I2C bus? (Objective 1)

    1. เพราะ sensor bus เร็วเกินไปสำหรับ matrix
    2. เพราะ sensor bus อยู่บน domain 1.8 V ส่วน matrix อยู่บน bus 3.3 V ร่วมกับจอ/ทัช ซึ่ง framework init ไว้แล้ว จึงใช้ context disp_touch_i2c_controller_context ได้เลย
    3. เพราะ sensor bus เป็น I3C ใช้กับอุปกรณ์ I2C ไม่ได้
    4. เพราะ matrix ต้องใช้ interrupt ของจอ
    Show answer

    Answer: B. เพราะ sensor bus อยู่บน domain 1.8 V ส่วน matrix อยู่บน bus 3.3 V ร่วมกับจอ/ทัช ซึ่ง framework init ไว้แล้ว จึงใช้ context disp_touch_i2c_controller_context ได้เลย

    main.c ของ master ระบุว่า sensor I2C (SCB0) อยู่บน 1.8 V domain ส่วน README ของตัวอย่างบอกว่า matrix อยู่บน bus 3.3 V ที่ใช้ร่วมกับจอ การต่ออุปกรณ์ผิดระดับแรงดันอาจสื่อสารไม่ได้หรือทำให้ขาเสียหาย และการใช้ SCB ที่ถูก init แล้วร่วมกันต้องไม่ init ซ้ำจนทัชเสีย (master มีทั้ง sensor I2C และ I3C แยกกัน)

  4. In Pot → RGB Mixer, with R = 70 %, G = 30 % and B = 60 %, what colour does the matrix show? (Objective 2)

    1. YELLOW
    2. CYAN
    3. WHITE
    4. PURPLE
    Show answer

    Answer: D. PURPLE

    แต่ละช่องที่ raw ≥ MIX_THRESHOLD (2048 ≈ 50 %) เปิดบิตของตัวเอง b0 = R, b1 = G, b2 = B ได้ bits = 1 + 4 = 5 ซึ่งตรงกับ RGB_PANEL_COLOR_PURPLE เพราะ enum สีของ DFR0522 ใช้การแพ็กบิต RGB แบบเดียวกัน

  5. Why does the mixer call rgb_panel_fill() only when the colour changes, although it reads the pots every 120 ms? (Objective 2)

    1. แต่ละคำสั่งส่ง frame 51 ไบต์บน bus ที่ใช้ร่วมกับทัช และการเขียนบล็อก LVGL timer ระหว่างส่ง การเขียนเฉพาะตอนเปลี่ยนจึงลดภาระ bus และไม่ทำให้แผงกระพริบ
    2. เพราะ DFR0522 รับคำสั่งได้วินาทีละครั้ง
    3. เพราะ ADC ต้องพักระหว่างการเขียน I2C
    4. เพราะ LVGL ห้ามเรียก I2C ใน timer
    Show answer

    Answer: A. แต่ละคำสั่งส่ง frame 51 ไบต์บน bus ที่ใช้ร่วมกับทัช และการเขียนบล็อก LVGL timer ระหว่างส่ง การเขียนเฉพาะตอนเปลี่ยนจึงลดภาระ bus และไม่ทำให้แผงกระพริบ

    rgb_panel_write() ส่ง RGB_PANEL_TX_SIZE = 51 ไบต์ทุกคำสั่ง โดยเขียนทีละไบต์แบบรอผล ซึ่งทำงานใน mix_timer_cb() ของ LVGL การเทียบกับ s_last_color จึงประหยัดทั้งเวลาและ bus ข้อสังเกต: s_last_color เริ่มที่ WHITE เพื่อบังคับเขียนครั้งแรก แต่ถ้าสีแรกเป็น WHITE พอดีจะยังไม่ถูกเขียนจนกว่าสีเปลี่ยน

Cite this lesson

If you teach from this lesson or reuse it in slides or documents, credit it with the text below. If you changed it, add (adapted) after the title.

"Driving an RGB dot matrix over I2C" from TESA Open Knowledge by the Thai Embedded Systems Association (TESA), https://github.com/tesaiot/tesa-qualification-program, licensed under CC BY-NC 4.0

Thai attribution: "ควบคุม RGB dot matrix ผ่าน I2C" จาก TESA Open Knowledge โดยสมาคมสมองกลฝังตัวไทย (Thai Embedded Systems Association: TESA) https://github.com/tesaiot/tesa-qualification-program สัญญาอนุญาต CC BY-NC 4.0

Lesson link: https://tesaiot.github.io/tesa-qualification-program/en/courses/tesaiot-firmware-stack/m04-qwa309-hardware/l03-rgb-matrix-i2c/

This lesson adapts the source below; keep its credit too.
https://github.com/tesaiot/developer-hub/blob/e5c772252e7d20f715463e0d27df9ece4e569c38/prac_qwa309_rgb_matrix · Code stays in the Developer Hub and is linked at pinned commits, never copied: the episodes, practice codes and main-branch examples are Apache-2.0; the master template and the OPTIGA client carry Infineon/Cypress EULAs.

Full guide: how to cite TESA

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · CC BY-NC 4.0

Content is licensed CC BY-NC 4.0. Reuse it non-commercially and credit the Thai Embedded Systems Association (TESA) every time. · How to cite TESA