/* ============================================================================
* BARE-METAL SPI TFT + I2C TOUCH HEART DEMO
* for Arduino Uno R3 (ATmega328P) + Wokwi board-ili9341-cap-touch
* ============================================================================
*
* WHAT THIS DOES:
* - Talks directly to the ATmega328P's hardware registers (no Arduino
* libraries for SPI, I2C, GPIO, or Serial). The Arduino toolchain still
* compiles it, but every actual hardware operation is register-level.
* - Drives an ILI9341 240x320 TFT display over SPI to draw a big heart.
* - Reads a capacitive touch controller (FT6206) over I2C.
* - Cycles the heart through 9 colors each time you tap the screen.
* - Logs every SPI byte and every I2C transaction to the serial monitor
* so you can see the protocol in action.
*
* THE BIG GOTCHA THAT TOOK ME A WHILE TO FIGURE OUT:
* Wokwi's `board-ili9341-cap-touch` part has TWO different chips on it,
* talking on TWO different buses:
*
* ILI9341 (display) -> SPI on D11/D12/D13
* FT6206 (touch sensor) -> I2C on A4/A5
*
* I originally assumed the touch was an XPT2046 (the old resistive-style
* SPI touch chip). Nope. "Cap" in the name = capacitive = I2C. If you
* see touch code online that uses XPT2046 control bytes (0x90, 0xD0),
* that's the wrong chip for this Wokwi part. Took me an embarrassing
* amount of debugging to figure that out.
*
* PIN MAP (Arduino Uno R3 side):
* D8 -> TFT RST (PB0) hardware reset, active low
* D9 -> TFT D/C (PB1) data/command select: LOW=cmd, HIGH=data
* D10 -> TFT CS (PB2) display chip select, active low
* D11 -> TFT MOSI (PB3) SPI data out (Uno -> display)
* D12 -> TFT MISO (PB4) SPI data in (unused here, but wire it anyway)
* D13 -> TFT SCK (PB5) SPI clock
* A4 -> TCH SDA (PC4) I2C data
* A5 -> TCH SCL (PC5) I2C clock
*
* WHY USE BARE METAL AT ALL?
* Three reasons:
* 1. You actually understand what's happening on the wire.
* 2. It's faster (Arduino's digitalWrite is famously slow).
* 3. The code is portable to any ATmega328P environment, not just
* the Arduino IDE. Same code would run on a bare AVR with avr-gcc.
*
* Downside: you have to read datasheets. Welcome to embedded.
* ============================================================================ */
#include <avr/io.h> /* Register definitions: PORTB, DDRB, SPCR, etc.
Pulls in the right header for ATmega328P based on
the -mmcu flag (Arduino sets this automatically). */
#include <util/delay.h> /* _delay_ms() and _delay_us(). These are busy-wait
loops sized at compile time using F_CPU. */
#include <stdint.h> /* uint8_t, uint16_t, etc. Always use these in embedded
code. `int` is 16-bit on AVR but 32-bit on ARM, so
being explicit avoids surprises when porting. */
/* ============================================================================
* TFT PIN MACROS
* ----------------------------------------------------------------------------
* All TFT control pins live on PORTB (pins D8-D13 on the Uno). Defining macros
* for the pin numbers lets us write `(1 << TFT_CS_PIN)` instead of memorizing
* bit positions everywhere.
*
* PB0..PB5 are the AVR pin names; the Arduino numbers (D8..D13) map to them
* in that order. So D10 == PB2, D11 == PB3, etc.
* ============================================================================ */
#define TFT_CS_PIN PB2 /* Arduino D10 */
#define DC_PIN PB1 /* Arduino D9 */
#define RST_PIN PB0 /* Arduino D8 */
#define MOSI_PIN PB3 /* Arduino D11 - controlled by SPI hardware, but we
still need to set its DDR bit so SPI can drive it */
#define SCK_PIN PB5 /* Arduino D13 - same deal: SPI hw drives, we set DDR */
/* These macros set or clear a single pin. The pattern is:
* PORTB |= (1 << pin) -> set HIGH
* PORTB &= ~(1 << pin) -> set LOW
* The compiler turns these into single SBI/CBI instructions on AVR. Fast. */
#define TFT_CS_LOW() (PORTB &= ~(1 << TFT_CS_PIN)) /* select TFT */
#define TFT_CS_HIGH() (PORTB |= (1 << TFT_CS_PIN)) /* deselect TFT */
#define DC_LOW() (PORTB &= ~(1 << DC_PIN)) /* "next byte is a command" */
#define DC_HIGH() (PORTB |= (1 << DC_PIN)) /* "next byte is data" */
#define RST_LOW() (PORTB &= ~(1 << RST_PIN)) /* assert reset (active low) */
#define RST_HIGH() (PORTB |= (1 << RST_PIN)) /* release reset */
/* ============================================================================
* ILI9341 COMMAND CODES
* ----------------------------------------------------------------------------
* These come straight out of the ILI9341 datasheet. Only the ones we actually
* use are defined here - the chip has dozens more for things like gamma
* curves, voltage tweaking, and partial-display modes that we don't need.
* ============================================================================ */
#define CMD_SOFTWARE_RESET 0x01 /* reset the controller's state machine */
#define CMD_SLEEP_OUT 0x11 /* wake up from sleep mode (chip boots in sleep) */
#define CMD_DISPLAY_ON 0x29 /* actually turn on the pixels */
#define CMD_COLUMN_ADDR_SET 0x2A /* set the X range we're about to write to */
#define CMD_PAGE_ADDR_SET 0x2B /* set the Y range. (Yes, "page" means row.
No, I don't know why they called it that.) */
#define CMD_MEMORY_WRITE 0x2C /* "ok, the next data bytes are pixel colors" */
#define CMD_MEMORY_ACCESS 0x36 /* MADCTL: rotation, RGB/BGR order, mirroring */
#define CMD_PIXEL_FORMAT 0x3A /* COLMOD: how many bits per pixel */
/* ============================================================================
* FT6206 CAPACITIVE TOUCH CONTROLLER (I2C)
* ----------------------------------------------------------------------------
* The FT6206 is the touch chip Wokwi simulates. It speaks I2C. The address
* is fixed at 0x38 (7-bit). To read touch data, you read register 0x02 and
* the chip auto-increments through registers 0x03..0x06 to give you X and Y.
*
* Register 0x02 layout: bottom nibble = number of touch points (0, 1, or 2).
* Registers 0x03/0x04 = X high byte / low byte for touch point 1.
* Registers 0x05/0x06 = Y high byte / low byte for touch point 1.
*
* GOTCHA: the high byte's TOP nibble holds flags (event type), not data.
* Real coordinates are 12 bits, so we mask with 0x0F when extracting.
* ============================================================================ */
#define FT6206_ADDR 0x38 /* 7-bit I2C address. The R/W bit gets
tacked on when we shift left by 1. */
#define FT6206_REG_NUMTOUCHES 0x02
#define FT6206_REG_P1_XH 0x03
#define FT6206_REG_P1_XL 0x04
#define FT6206_REG_P1_YH 0x05
#define FT6206_REG_P1_YL 0x06
/* ============================================================================
* COLORS (RGB565 format)
* ----------------------------------------------------------------------------
* The ILI9341 stores 16 bits per pixel: 5 bits red, 6 bits green, 5 bits blue.
* Green gets the extra bit because the human eye is more sensitive to green.
*
* bit: 15 14 13 12 11 | 10 9 8 7 6 5 | 4 3 2 1 0
* R R R R R | G G G G G G | B B B B B
*
* So pure red = 0xF800 (top 5 bits set), green = 0x07E0, blue = 0x001F.
* ============================================================================ */
#define COLOR_BLACK 0x0000
#define COLOR_RED 0xF800
#define COLOR_PINK 0xFB56
#define COLOR_ORANGE 0xFD20
#define COLOR_YELLOW 0xFFE0 /* red + green = yellow */
#define COLOR_GREEN 0x07E0
#define COLOR_CYAN 0x07FF /* green + blue = cyan */
#define COLOR_BLUE 0x001F
#define COLOR_MAGENTA 0xF81F /* red + blue = magenta */
#define COLOR_WHITE 0xFFFF /* all bits = white */
#define SCREEN_W 240 /* native portrait dimensions of the ILI9341 */
#define SCREEN_H 320
/* The cycle of colors we step through on each tap. Order is just an aesthetic
* choice - feel free to rearrange. */
static const uint16_t color_cycle[] = {
COLOR_RED, COLOR_PINK, COLOR_ORANGE, COLOR_YELLOW,
COLOR_GREEN, COLOR_CYAN, COLOR_BLUE, COLOR_MAGENTA, COLOR_WHITE
};
static const char *color_names[] = {
"RED", "PINK", "ORANGE", "YELLOW",
"GREEN", "CYAN", "BLUE", "MAGENTA", "WHITE"
};
#define NUM_COLORS (sizeof(color_cycle) / sizeof(color_cycle[0]))
/* ^ Standard C trick: total array size / single element size = element count.
* Beats hardcoding 9 because if you add a color, the count auto-updates. */
/* ============================================================================
* USART (Serial Monitor) - bare metal
* ----------------------------------------------------------------------------
* This is what `Serial.print()` does under the hood, minus the Stream class
* abstraction layer. We talk directly to the AVR's USART hardware.
*
* The magic number 103 in UBRR0L is the baud rate divisor for 9600 baud at
* 16 MHz with the U2X bit clear:
* UBRR = (F_CPU / (16 * baud)) - 1 = (16000000 / 153600) - 1 = 103
* ============================================================================ */
static void usart_init(void) {
UBRR0H = 0; /* high byte of baud divisor */
UBRR0L = 103; /* low byte: 9600 @ 16 MHz */
UCSR0B = (1 << TXEN0); /* enable transmitter only
(we never read from serial,
so no point enabling RX) */
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00); /* 8 data bits, 1 stop, no parity
(the standard 8-N-1 setup) */
}
/* Send one character. Spins until the USART is ready to accept a new byte.
* UDRE0 = "USART Data Register Empty" - it's set when UDR0 is free. */
static void usart_tx(char c) {
while (!(UCSR0A & (1 << UDRE0))) { } /* spin while the TX buffer is full */
UDR0 = c; /* writing UDR0 kicks off the TX */
}
/* Print a null-terminated string. Standard C idiom: `*s++` returns the char
* at s, then advances s. Loop ends when we hit the '\0' terminator. */
static void usart_print(const char *s) { while (*s) usart_tx(*s++); }
/* Print a byte as "0xAB". Useful for logging protocol bytes. */
static void usart_print_hex(uint8_t b) {
const char hex[] = "0123456789ABCDEF";
usart_print("0x");
usart_tx(hex[(b >> 4) & 0x0F]); /* upper nibble */
usart_tx(hex[b & 0x0F]); /* lower nibble */
}
/* Print an unsigned 16-bit number in decimal. We build the digits backwards
* (least significant first) into a small buffer, then print them in reverse.
* Why not snprintf()? It pulls in ~1.5KB of stdio code we don't need. */
static void usart_print_dec(uint16_t v) {
char buf[6]; /* max 5 digits for uint16_t (65535) + slack */
int8_t i = 0;
if (v == 0) { usart_tx('0'); return; }
while (v > 0 && i < 6) { buf[i++] = '0' + (v % 10); v /= 10; }
while (i--) usart_tx(buf[i]); /* now print in reverse */
}
/* ============================================================================
* SPI - bare metal master mode
* ----------------------------------------------------------------------------
* The ATmega328P has a hardware SPI peripheral. We just configure it once
* and then writing to SPDR shifts a byte out (and reads one in simultaneously,
* since SPI is full-duplex).
*
* Mode 0 = clock idle low, sample on rising edge. This is what the ILI9341
* expects (and pretty much every SPI chip's default).
* ============================================================================ */
static void spi_init(void) {
/* Step 1: set MOSI, SCK, and our control pins (CS/DC/RST) as outputs.
* MISO stays as input (default) because the slave drives it.
*
* IMPORTANT: even though SPI hardware drives MOSI and SCK, you still
* have to set them as outputs in DDR. That's a classic "why isn't my
* SPI working" gotcha. */
DDRB |= (1 << MOSI_PIN) | (1 << SCK_PIN) | (1 << TFT_CS_PIN)
| (1 << DC_PIN) | (1 << RST_PIN);
/* Step 2: deselect the TFT before we configure SPI. If CS is left low
* during init, the slave will see garbage clock pulses. */
TFT_CS_HIGH();
/* Step 3: configure SPI control register.
* SPE = SPI Enable
* MSTR = Master mode (we generate the clock)
* CPOL=0, CPHA=0 (Mode 0) - both bits cleared by default
* SPR1:0 = 00 -> fosc/4 = 4 MHz... but we override with SPI2X below. */
SPCR = (1 << SPE) | (1 << MSTR);
/* Step 4: turn on double-speed bit. With SPR=00 and SPI2X=1, the clock
* runs at fosc/2 = 8 MHz. The ILI9341 can handle up to ~10 MHz comfortably,
* so we're fine. Faster = more pixels per second = smoother drawing. */
SPSR = (1 << SPI2X);
}
/* The fundamental SPI operation: simultaneously send a byte and receive one.
* 1. Write to SPDR (Serial Peripheral Data Register) - this kicks off the
* transfer immediately.
* 2. Wait for SPIF (SPI Interrupt Flag) in SPSR to be set, indicating the
* transfer is complete (8 clock pulses sent).
* 3. Read SPDR to get whatever the slave sent us back.
*
* Reading SPSR followed by SPDR also clears SPIF, which is what we want for
* the next call. */
static uint8_t spi_transfer(uint8_t b) {
SPDR = b; /* go! */
while (!(SPSR & (1 << SPIF))) { } /* wait until done (~1us at 8MHz) */
return SPDR; /* return whatever came back */
}
/* ============================================================================
* TWI (I2C) - bare metal master at 100 kHz
* ----------------------------------------------------------------------------
* Atmel calls I2C "TWI" (Two-Wire Interface) for trademark reasons - it's
* the same protocol though. Same pins on Uno: A4 = SDA, A5 = SCL.
*
* 100 kHz is the I2C standard mode speed. The FT6206 supports 400 kHz
* (fast mode) but 100 kHz is plenty for reading touch coordinates a few
* times per second.
*
* Baud calculation:
* SCL_freq = F_CPU / (16 + 2 * TWBR * prescaler)
* 100,000 = 16,000,000 / (16 + 2 * 72 * 1)
* -> TWBR = 72, prescaler = 1
* ============================================================================ */
static void twi_init(void) {
TWSR = 0x00; /* prescaler bits = 00 -> prescaler = 1 */
TWBR = 72; /* gives us 100 kHz SCL */
TWCR = (1 << TWEN); /* enable the TWI peripheral */
/* Note: we don't enable internal pull-ups on SDA/SCL because Wokwi
* provides them on the simulated module. On real hardware you'd want
* external 4.7kohm pull-ups to VCC. */
}
/* Transmit a START condition. This is the "hey everyone, I'm about to address
* someone" signal on I2C. After this, the bus is reserved for us until we
* send STOP.
*
* The dance with TWCR is always:
* 1. Set TWINT (ack the previous interrupt) + the action bit (TWSTA, TWSTO,
* or just TWEN for a regular byte transfer).
* 2. Wait for TWINT to come back HIGH, meaning the operation completed. */
static void twi_start(void) {
TWCR = (1 << TWINT) | (1 << TWSTA) | (1 << TWEN);
while (!(TWCR & (1 << TWINT))) { }
}
/* Transmit a STOP condition. Releases the bus. After this, anyone can talk.
*
* STOP is special: it doesn't set TWINT when it completes, so we can't poll
* for it the way we do for other operations. Just wait a few microseconds
* to make sure the lines have had time to settle. */
static void twi_stop(void) {
TWCR = (1 << TWINT) | (1 << TWSTO) | (1 << TWEN);
_delay_us(10);
}
/* Write one byte and return the status register value (with prescaler bits
* masked off). Common status codes:
* 0x18 = SLA+W transmitted, ACK received
* 0x28 = data byte transmitted, ACK received
* 0x40 = SLA+R transmitted, ACK received
* 0x20 / 0x30 / 0x48 = NACK responses (something's wrong) */
static uint8_t twi_write(uint8_t b) {
TWDR = b; /* load the data register */
TWCR = (1 << TWINT) | (1 << TWEN); /* kick off the transmission */
while (!(TWCR & (1 << TWINT))) { } /* wait for completion */
return TWSR & 0xF8; /* status code, no prescaler bits */
}
/* Read one byte and ACK it (telling the slave "send me another"). */
static uint8_t twi_read_ack(void) {
TWCR = (1 << TWINT) | (1 << TWEN) | (1 << TWEA); /* TWEA = enable ACK */
while (!(TWCR & (1 << TWINT))) { }
return TWDR;
}
/* Read one byte and NACK it (telling the slave "I'm done, no more bytes").
* You always NACK the last byte of a read - this is required by the I2C spec. */
static uint8_t twi_read_nack(void) {
TWCR = (1 << TWINT) | (1 << TWEN); /* TWEA cleared = NACK */
while (!(TWCR & (1 << TWINT))) { }
return TWDR;
}
/* High-level I2C read: read N bytes from a register on a 7-bit-addressed device.
*
* The protocol is the standard "set register pointer, then read":
* 1. START
* 2. Send (addr << 1 | 0) for write mode -- expect ACK
* 3. Send the register number -- expect ACK
* 4. REPEATED START (instead of STOP+START - faster, atomic on the bus)
* 5. Send (addr << 1 | 1) for read mode -- expect ACK
* 6. Read N bytes, ACK all but the last
* 7. STOP
*
* The shift-and-OR is because the I2C address byte is { 7-bit address, R/W bit }.
* R/W = 0 means write, R/W = 1 means read. */
static uint8_t twi_read_regs(uint8_t addr7, uint8_t reg, uint8_t *buf, uint8_t n) {
usart_print("I2C: START addr=");
usart_print_hex(addr7);
usart_print(" W reg=");
usart_print_hex(reg);
usart_print("\r\n");
twi_start();
/* 0x18 = "SLA+W transmitted, ACK received". If we get something else,
* the device isn't responding - maybe wrong address or not connected. */
if (twi_write((addr7 << 1) | 0) != 0x18) {
usart_print("I2C: NACK on SLA+W (device not responding?)\r\n");
twi_stop();
return 0;
}
/* 0x28 = "data byte transmitted, ACK received" */
if (twi_write(reg) != 0x28) {
usart_print("I2C: NACK on register byte\r\n");
twi_stop();
return 0;
}
usart_print("I2C: REPEATED START addr=");
usart_print_hex(addr7);
usart_print(" R\r\n");
twi_start(); /* This is a "repeated start" - same TWSTA bit, the hardware
knows we're already in a transaction and handles it. */
/* 0x40 = "SLA+R transmitted, ACK received" */
if (twi_write((addr7 << 1) | 1) != 0x40) {
usart_print("I2C: NACK on SLA+R\r\n");
twi_stop();
return 0;
}
/* Read all N bytes. ACK every byte except the last - that's an I2C
* requirement: the master signals "I'm done" by NACKing the final byte. */
for (uint8_t i = 0; i < n; i++) {
buf[i] = (i == n - 1) ? twi_read_nack() : twi_read_ack();
usart_print("I2C: read byte=");
usart_print_hex(buf[i]);
usart_print("\r\n");
}
twi_stop();
usart_print("I2C: STOP\r\n");
return 1; /* success */
}
/* ============================================================================
* TFT WRITE HELPERS - with serial logging
* ----------------------------------------------------------------------------
* These wrap spi_transfer() with the right CS/DC pin manipulation for the
* ILI9341's "command vs data" protocol:
* - DC LOW + CS LOW = "this byte is a command"
* - DC HIGH + CS LOW = "this byte is data (parameter or pixel)"
* - CS HIGH = ignore me
*
* The protocol is: assert CS, set DC for cmd or data, shift the byte, release CS.
* ============================================================================ */
static void tft_write_cmd(uint8_t cmd) {
usart_print("SPI CMD byte="); usart_print_hex(cmd); usart_print("\r\n");
DC_LOW(); /* "next byte is a command" */
TFT_CS_LOW(); /* select the TFT */
spi_transfer(cmd); /* send the byte */
TFT_CS_HIGH(); /* deselect */
}
static void tft_write_data(uint8_t d) {
usart_print("SPI DATA byte="); usart_print_hex(d); usart_print("\r\n");
DC_HIGH();
TFT_CS_LOW();
spi_transfer(d);
TFT_CS_HIGH();
}
/* Convenience: send a 16-bit value as two data bytes (high then low).
* The ILI9341 always wants big-endian (high byte first) for multi-byte values. */
static void tft_write_data16(uint16_t v) {
tft_write_data((uint8_t)(v >> 8));
tft_write_data((uint8_t)(v & 0xFF));
}
/* When we're streaming hundreds of pixels (like for a fill), per-byte
* CS toggling is wasteful. Instead, hold CS low for the whole stream.
* The ILI9341 happily accepts a continuous run of pixel data after a
* MEMORY_WRITE command. We also skip serial logging during streams - it
* would generate ~150,000 lines of output for a full screen fill. */
static void tft_begin_pixel_stream(void) {
DC_HIGH(); /* data mode */
TFT_CS_LOW(); /* and we'll keep CS low until end_pixel_stream */
}
static void tft_end_pixel_stream(void) { TFT_CS_HIGH(); }
static void tft_stream_pixel(uint16_t c) {
spi_transfer((uint8_t)(c >> 8)); /* high byte */
spi_transfer((uint8_t)(c & 0xFF)); /* low byte */
}
/* ============================================================================
* TFT INITIALIZATION
* ----------------------------------------------------------------------------
* Every TFT controller wants a specific dance to wake up. Most of this
* comes straight from the ILI9341 datasheet's "initialization sequence"
* section. The delays matter - skip them and the chip won't be ready.
* ============================================================================ */
/* Hardware reset via the RST pin. Pulse it low for at least 10us, then wait
* for the chip to come back online (~150 ms). This guarantees a known state
* regardless of what the chip was doing before. */
static void tft_hw_reset(void) {
usart_print("\r\n--- TFT HARDWARE RESET ---\r\n");
RST_HIGH(); _delay_ms(5); /* make sure we start from HIGH */
RST_LOW(); _delay_ms(20); /* assert reset (datasheet: min 10us, but
20ms is fine and more reliable in noisy
environments) */
RST_HIGH(); _delay_ms(150); /* release and wait for chip to boot */
}
static void tft_init(void) {
tft_hw_reset();
usart_print("--- TFT INIT SEQUENCE ---\r\n");
/* Software reset: clears the controller's state. Belt-and-suspenders
* with the hardware reset, but cheap insurance. Datasheet says wait
* 5ms, but 150ms covers all cases. */
tft_write_cmd(CMD_SOFTWARE_RESET); _delay_ms(150);
/* The chip boots in sleep mode to save power. Wake it up. */
tft_write_cmd(CMD_SLEEP_OUT); _delay_ms(120);
/* Pixel format 0x55 = 16 bits per pixel (RGB565). The other common option
* is 0x66 = 18 bits per pixel, but that wastes bandwidth and ATmega328P
* doesn't have RAM to buffer it nicely. */
tft_write_cmd(CMD_PIXEL_FORMAT); tft_write_data(0x55);
/* MADCTL = Memory Access Control. The 0x48 value means:
* bit 7 (MY) = 0: don't flip Y
* bit 6 (MX) = 1: flip X
* bit 5 (MV) = 0: no row/column exchange (portrait orientation)
* bit 3 (BGR) = 1: panel is BGR-ordered (most are; without this
* flag your reds and blues are swapped)
* If your colors look wrong, this is the byte to fiddle with. */
tft_write_cmd(CMD_MEMORY_ACCESS); tft_write_data(0x48);
/* Last step: turn the actual pixels on. Up to now everything was
* configured but the panel was off. */
tft_write_cmd(CMD_DISPLAY_ON); _delay_ms(100);
usart_print("--- TFT INIT DONE ---\r\n\r\n");
}
/* Set the "address window" - the rectangular region we're about to write
* pixels into. After calling this and MEMORY_WRITE (which is the last step
* inside), every pixel data byte we send goes to the next position in the
* window, wrapping around at the right edge. This is what makes fillRect()
* fast: one window setup, then thousands of streamed pixels. */
static void tft_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) {
tft_write_cmd(CMD_COLUMN_ADDR_SET); /* X range */
tft_write_data16(x0); tft_write_data16(x1);
tft_write_cmd(CMD_PAGE_ADDR_SET); /* Y range */
tft_write_data16(y0); tft_write_data16(y1);
tft_write_cmd(CMD_MEMORY_WRITE); /* "ok, ready for pixels" */
}
/* Fill the entire screen with one color. 240 * 320 = 76,800 pixels = 153,600
* bytes. At 8 MHz SPI, that's ~150 ms. Not blazing but acceptable. */
static void tft_fill_screen(uint16_t color) {
tft_set_window(0, 0, SCREEN_W - 1, SCREEN_H - 1);
tft_begin_pixel_stream();
uint32_t n = (uint32_t)SCREEN_W * SCREEN_H;
while (n--) tft_stream_pixel(color);
tft_end_pixel_stream();
}
/* ============================================================================
* HEART BITMAP
* ----------------------------------------------------------------------------
* 16 rows x 16 columns. Each row is a uint16_t where each bit is one pixel:
* 1 = foreground (heart color), 0 = background.
*
* The shape is hand-drawn. If you squint at the binary literals you can see
* the heart - two lobes at the top, tapering to a point at the bottom. The
* binary literals (0b...) are a GCC extension; standard C only has hex.
*
* When drawn, each "pixel" gets scaled up (e.g. by 10x) so the bitmap shows
* up as a 160x160 heart on the 240x320 screen.
* ============================================================================ */
static const uint16_t heart_bitmap[16] = {
0b0000000000000000,
0b0001100000110000, /* ## ## */
0b0011110001111000, /* #### #### */
0b0111111011111100, /* ###### ##### */
0b0111111111111100, /* ############ */
0b1111111111111110, /* ############# */
0b1111111111111110,
0b1111111111111110,
0b1111111111111110,
0b0111111111111100,
0b0111111111111100,
0b0011111111111000, /* ########## */
0b0001111111110000, /* ######## */
0b0000111111100000, /* ###### */
0b0000011111000000, /* ##### */
0b0000001110000000, /* ### */
};
/* Draw the heart, scaled by `scale` so each bitmap pixel becomes a
* scale x scale block. Centered at (cx, cy).
*
* The four nested loops look scary but they're just:
* for each row in bitmap:
* for each scale-up vertical repeat:
* for each column in bitmap:
* for each scale-up horizontal repeat:
* emit one pixel
*
* We hold CS low for the whole thing - it's one big pixel stream. */
static void draw_heart(uint16_t cx, uint16_t cy, uint8_t scale,
uint16_t fg, uint16_t bg) {
uint16_t side = 16 * scale;
uint16_t x0 = cx - side / 2; /* top-left corner of the scaled bitmap */
uint16_t y0 = cy - side / 2;
tft_set_window(x0, y0, x0 + side - 1, y0 + side - 1);
tft_begin_pixel_stream();
for (uint8_t row = 0; row < 16; row++) {
for (uint8_t sy = 0; sy < scale; sy++) { /* vertical scaling */
uint16_t bits = heart_bitmap[row];
for (uint8_t col = 0; col < 16; col++) {
/* Test bit (15 - col) because we want column 0 to be the
* leftmost bit (MSB), matching the visual layout above. */
uint16_t color = (bits & (1 << (15 - col))) ? fg : bg;
for (uint8_t sx = 0; sx < scale; sx++) { /* horizontal scaling */
tft_stream_pixel(color);
}
}
}
}
tft_end_pixel_stream();
}
/* ============================================================================
* FT6206 TOUCH READING
* ----------------------------------------------------------------------------
* Reads 5 bytes starting at register 0x02 (NUMTOUCHES). The chip auto-
* increments its internal pointer, so we get [num, X_hi, X_lo, Y_hi, Y_lo]
* in one transaction.
*
* Returns 1 if a touch is detected, 0 otherwise. *out_x and *out_y get the
* coordinates if there's a touch.
*
* GOTCHA: the high byte's top nibble holds event flags, not coordinate
* bits. The actual X/Y coords are 12 bits. So we mask with 0x0F.
* ============================================================================ */
static uint8_t touch_read(uint16_t *out_x, uint16_t *out_y) {
uint8_t buf[5];
if (!twi_read_regs(FT6206_ADDR, FT6206_REG_NUMTOUCHES, buf, 5)) return 0;
uint8_t num = buf[0];
if (num == 0 || num > 2) return 0; /* no touches, or bogus reading */
/* Combine the high and low bytes. Mask the high byte's top nibble - that's
* "event type" flags (press, lift, contact), not part of the coordinate. */
uint16_t x = ((uint16_t)(buf[1] & 0x0F) << 8) | buf[2];
uint16_t y = ((uint16_t)(buf[3] & 0x0F) << 8) | buf[4];
if (out_x) *out_x = x;
if (out_y) *out_y = y;
return 1;
}
/* ============================================================================
* ARDUINO ENTRY POINTS
* ----------------------------------------------------------------------------
* The Arduino toolchain auto-generates a main() that calls setup() once and
* then loops loop() forever. We use those names so the Arduino IDE / Wokwi
* builds correctly, but the contents are 100% bare-metal register access.
*
* These vars are file-scope `static` so they persist across loop() calls -
* loop() returns and gets called again, so locals would reset every time.
* ============================================================================ */
static uint8_t color_idx = 0; /* current index into color_cycle[] */
static uint8_t prev_pressed = 0; /* for edge detection (touch debouncing) */
static uint16_t debug_counter = 0; /* heartbeat throttle */
void setup() {
/* Bring up serial first so we can log everything else. */
usart_init();
usart_print("\r\n=== Bare-Metal: SPI TFT + I2C Touch ===\r\n");
usart_print("Click the TFT to cycle the heart color.\r\n\r\n");
/* Configure both buses. They're independent - SPI on PORTB, TWI on PORTC. */
spi_init();
twi_init();
/* Now bring up the display and paint the initial state. */
tft_init();
tft_fill_screen(COLOR_BLACK);
usart_print("\r\n--- Drawing initial heart: ");
usart_print(color_names[color_idx]);
usart_print(" ---\r\n");
draw_heart(SCREEN_W / 2, SCREEN_H / 2, 10,
color_cycle[color_idx], COLOR_BLACK);
usart_print("\r\n--- Setup complete, entering main loop ---\r\n\r\n");
}
void loop() {
uint16_t tx = 0, ty = 0;
/* Poll the FT6206 for a touch. Each poll = one full I2C transaction
* logged to serial: START, SLA+W, reg, repeated START, SLA+R, 5 bytes,
* STOP. That's about 18 bytes on the wire at 100 kHz = ~2 ms. */
uint8_t pressed = touch_read(&tx, &ty);
/* Heartbeat: print a status line about once per second. This gives you
* visual feedback that the loop is running and the I2C is healthy, even
* when no touches are happening. 10 polls * 100ms delay = 1 second. */
if (++debug_counter >= 10) {
debug_counter = 0;
usart_print("[heartbeat] ");
usart_print(pressed ? "TOUCH x=" : "idle x=");
usart_print_dec(tx);
usart_print(" y=");
usart_print_dec(ty);
usart_print("\r\n");
}
/* Edge detection: only act on the rising edge (idle -> pressed).
* If we acted on every "pressed" reading, holding your finger down would
* cycle through all the colors in a flash. The `&& !prev_pressed` makes
* each tap count exactly once. */
if (pressed && !prev_pressed) {
color_idx = (color_idx + 1) % NUM_COLORS;
usart_print("\r\n>>> TOUCH @ x=");
usart_print_dec(tx);
usart_print(" y=");
usart_print_dec(ty);
usart_print(" -> ");
usart_print(color_names[color_idx]);
usart_print(" <<<\r\n");
/* Redraw the heart in the new color. We don't need to clear the
* background - draw_heart() paints both fg and bg pixels, so it
* fully overwrites whatever was there before. */
draw_heart(SCREEN_W / 2, SCREEN_H / 2, 10,
color_cycle[color_idx], COLOR_BLACK);
usart_print("--- redraw done ---\r\n\r\n");
}
prev_pressed = pressed; /* remember state for next iteration's edge check */
_delay_ms(100); /* poll rate: 10 Hz. Plenty for human touch input. */
}
Loading
ili9341-cap-touch
ili9341-cap-touch