Files

382 lines
15 KiB
Arduino

// Teensy 4.1 + XPT2046 resistive touch calibration sketch.
//
// Phase 1 of plans/teensy41_signer_port.md. Calibrates the XPT2046 touch panel
// against the ST7796S 320x480 portrait display, then prints the calibration
// struct over USB CDC so it can be pasted into the signer firmware, and enters
// a live "draw a dot where you touch" mode to verify the calibration tracks
// across the full screen.
//
// Pin assignment (from firmware/teensy41/WIRING.md):
// t_cs = Teensy pin 9 (touch chip select)
// t_irq = Teensy pin 10 (touch interrupt — active low when pressed)
// sdi (mosi) = Teensy pin 11 (SPI0 MOSI, shared with TFT)
// sdo (miso) = Teensy pin 12 (SPI0 MISO, shared with TFT)
// sck = Teensy pin 13 (SPI0 SCK, shared with TFT)
// TFT: cs=5, rst=6, dc=7, bl=8 (same as tft_test.ino)
//
// Build / upload:
// arduino-cli compile --fqbn teensy:avr:teensy41 firmware/teensy41/touch_cal
// arduino-cli upload -p /dev/ttyACM0 --fqbn teensy:avr:teensy41 firmware/teensy41/touch_cal
//
// Exit criterion: 4-corner calibration completes, the printed calibration
// struct is sane (x_min < x_max, y_min < y_max, invert flags set correctly),
// and live dot-draw tracks the stylus across the full 320x480 portrait area.
#include <ST7796_t3.h>
#include <SPI.h>
// ---- Pin map (must match firmware/teensy41/WIRING.md) ----
#define TFT_CS 5
#define TFT_RST 6
#define TFT_DC 7
#define TFT_BL 8
#define T_CS 9
#define T_IRQ 2 // moved off pin 10 (SPI0 CS0) — pinMode on pin 10
// corrupts the SPI engine and reverts the display to
// landscape. Pin 2 is on the long edge, before pin 5.
#define SPI_MOSI 11
#define SPI_MISO 12
#define SPI_SCK 13
// ---- Display ----
ST7796_t3 tft = ST7796_t3(TFT_CS, TFT_DC, TFT_RST);
// Landscape 480x320 (rotation 1 with init(320,480), or rotation 0 with
// init(480,320)). We use init(320,480) + setRotation(1) — same as tft_test.ino
// which produced a correct edge-to-edge landscape fill.
#define SCREEN_W 480
#define SCREEN_H 320
// ---- Calibration struct (mirrors firmware/cyd_esp32_2432s028/main/touch.h) ----
struct TouchCal {
int x_min, x_max;
int y_min, y_max;
bool invert_x, invert_y;
};
static TouchCal s_cal = { 0, 4095, 0, 4095, false, false };
// ---- XPT2046 control bytes ----
// XPT2046 datasheet: 8-bit control, MSB first, start bit=1.
// 0xD0 = X position (channel 1, 12-bit, SER/DFR=1)
// 0x90 = Y position (channel 5, 12-bit, SER/DFR=1)
// 0xB0 = Z1 (pressure), 0xC0 = Z2 (pressure)
#define XPT_X 0xD0
#define XPT_Y 0x90
#define XPT_Z1 0xB0
#define XPT_Z2 0xC0
// XPT2046 max SPI clock is ~2.5 MHz; the TFT runs at 40 MHz. We must drop the
// clock before every touch read and restore it for TFT draws.
#define T_SPI_SPEED 2000000 // 2 MHz — safe for XPT2046
// ---- XPT2046 read via hardware SPI ----
// Reads one 12-bit channel. The XPT2046 control byte is 8 bits; the response is
// 12 bits returned in the next 16 clocks (top 12 bits are the sample).
static uint16_t xpt_read(uint8_t ctrl) {
SPI.beginTransaction(SPISettings(T_SPI_SPEED, MSBFIRST, SPI_MODE0));
digitalWrite(T_CS, LOW);
SPI.transfer(ctrl);
// Read 16 bits; the 12-bit sample is in the top 12 bits.
uint8_t hi = SPI.transfer(0x00);
uint8_t lo = SPI.transfer(0x00);
digitalWrite(T_CS, HIGH);
SPI.endTransaction();
uint16_t raw = ((uint16_t)hi << 8) | lo;
return raw >> 4; // 12-bit result
}
// Returns true if a touch is currently detected (IRQ low), with averaged raw
// X/Y/Z written to *x/*y/*z. Samples 7 times and averages; rejects if pressure
// is too low (stylus not pressing hard enough).
static bool touch_read_raw(uint16_t *x, uint16_t *y, uint16_t *z) {
if (digitalRead(T_IRQ) != 0) return false; // not pressed
uint32_t sx = 0, sy = 0, sz = 0;
int valid = 0;
for (int i = 0; i < 7; i++) {
uint16_t z1 = xpt_read(XPT_Z1);
uint16_t z2 = xpt_read(XPT_Z2);
uint16_t pressure = (z1 > 0 && z2 > z1) ? (uint16_t)(z1 + (4095 - z2)) : 0;
if (pressure < 80) continue; // too light
sx += xpt_read(XPT_X);
sy += xpt_read(XPT_Y);
sz += pressure;
++valid;
}
if (valid == 0) return false;
*x = (uint16_t)(sx / valid);
*y = (uint16_t)(sy / valid);
*z = (uint16_t)(sz / valid);
return true;
}
// Wait for a stable touch: accumulate 20 valid samples, average them. Bails
// and restarts if the stylus is lifted mid-press.
static void wait_for_stable_touch(uint16_t *out_x, uint16_t *out_y) {
uint32_t sx = 0, sy = 0;
int count = 0;
int idle = 0;
while (count < 20) {
uint16_t rx, ry, rz;
if (touch_read_raw(&rx, &ry, &rz)) {
sx += rx; sy += ry; ++count; idle = 0;
} else {
++idle;
if (count > 0 && idle > 25) { sx = 0; sy = 0; count = 0; }
}
delay(8);
}
*out_x = (uint16_t)(sx / count);
*out_y = (uint16_t)(sy / count);
}
// ---- Calibration ----
// Tap 4 corners (TL, TR, BR, BL), average the left/right and top/bottom pairs
// to get x_min/x_max/y_min/y_max, and set the invert flags based on which side
// produced the larger raw value.
static void run_calibration() {
const int margin = 20;
// IMPORTANT: use the SCREEN_W/SCREEN_H constants (320x480 portrait), NOT
// tft.width()/tft.height() — the ST7796_t3 accessors return the unrotated
// 480x320 dimensions after setRotation(1), which puts crosshairs off-screen.
const int w = SCREEN_W;
const int h = SCREEN_H;
const int tx[4] = { margin, w - margin, w - margin, margin };
const int ty[4] = { margin, margin, h - margin, h - margin };
const char *name[4] = { "TOP-LEFT", "TOP-RIGHT", "BOTTOM-RIGHT", "BOTTOM-LEFT" };
uint16_t raw_x[4], raw_y[4];
Serial.print("run_calibration: w="); Serial.print(w);
Serial.print(" h="); Serial.println(h);
for (int i = 0; i < 4; i++) {
Serial.print(" target "); Serial.print(name[i]);
Serial.print(" at ("); Serial.print(tx[i]); Serial.print(","); Serial.print(ty[i]); Serial.println(")");
tft.fillScreen(0x0000); // black
// Draw the crosshair FIRST (before text), so text rendering can't affect it.
draw_crosshair(tx[i], ty[i], 0x07E0); // green
tft.setTextColor(0xFFFF, 0x0000);
tft.setTextSize(2);
tft.setCursor(10, 10);
tft.print("Touch cal: tap ");
tft.setCursor(10, 40);
tft.print(name[i]);
Serial.print("Tap and hold "); Serial.println(name[i]);
wait_for_stable_touch(&raw_x[i], &raw_y[i]);
draw_crosshair(tx[i], ty[i], 0x001F); // blue = recorded
Serial.print(" raw x="); Serial.print(raw_x[i]);
Serial.print(" y="); Serial.println(raw_y[i]);
delay(350);
}
// The XPT2046 axes are swapped relative to the screen in portrait rotation 1:
// raw X maps to screen Y, raw Y maps to screen X. So we calibrate screen-X
// from the raw-Y values of the left vs right corners, and screen-Y from the
// raw-X values of the top vs bottom corners.
// screen-X: left corners = TL(0), BL(3); right corners = TR(1), BR(2)
// screen-Y: top corners = TL(0), TR(1); bottom corners = BR(2), BL(3)
int scr_x_left = ((int)raw_y[0] + (int)raw_y[3]) / 2; // raw Y at left
int scr_x_right = ((int)raw_y[1] + (int)raw_y[2]) / 2; // raw Y at right
int scr_y_top = ((int)raw_x[0] + (int)raw_x[1]) / 2; // raw X at top
int scr_y_bottom = ((int)raw_x[2] + (int)raw_x[3]) / 2; // raw X at bottom
// Store the calibration in terms of the RAW values that will be used as
// input to raw_to_screen(). Since we swap in raw_to_screen(), we store:
// x_min/x_max = range of raw Y that maps to screen X
// y_min/y_max = range of raw X that maps to screen Y
s_cal.x_min = min(scr_x_left, scr_x_right);
s_cal.x_max = max(scr_x_left, scr_x_right);
s_cal.y_min = min(scr_y_top, scr_y_bottom);
s_cal.y_max = max(scr_y_top, scr_y_bottom);
s_cal.invert_x = !(scr_x_right > scr_x_left); // if right raw < left raw, invert
s_cal.invert_y = !(scr_y_bottom > scr_y_top); // if bottom raw < top raw, invert
Serial.println();
Serial.println("=== Calibration result ===");
Serial.print("x_min="); Serial.print(s_cal.x_min);
Serial.print(" x_max="); Serial.print(s_cal.x_max);
Serial.print(" y_min="); Serial.print(s_cal.y_min);
Serial.print(" y_max="); Serial.print(s_cal.y_max);
Serial.print(" invert_x="); Serial.print(s_cal.invert_x ? 1 : 0);
Serial.print(" invert_y="); Serial.println(s_cal.invert_y ? 1 : 0);
Serial.println();
Serial.println("Paste this into the signer firmware:");
Serial.print("static TouchCal s_cal = { ");
Serial.print(s_cal.x_min); Serial.print(", ");
Serial.print(s_cal.x_max); Serial.print(", ");
Serial.print(s_cal.y_min); Serial.print(", ");
Serial.print(s_cal.y_max); Serial.print(", ");
Serial.print(s_cal.invert_x ? 1 : 0); Serial.print(", ");
Serial.print(s_cal.invert_y ? 1 : 0);
Serial.println(" };");
Serial.println();
}
static int map_clamped(int v, int in_min, int in_max, int out_min, int out_max) {
if (v < in_min) v = in_min;
if (v > in_max) v = in_max;
int den = in_max - in_min;
if (den == 0) return out_min;
return out_min + (v - in_min) * (out_max - out_min) / den;
}
// Map raw X/Y to screen pixels using s_cal.
// In portrait rotation 1, the XPT2046 axes are swapped: raw Y -> screen X,
// raw X -> screen Y. The calibration struct stores x_min/x_max as the range
// of raw Y that maps to screen X, and y_min/y_max as the range of raw X that
// maps to screen Y.
static void raw_to_screen(uint16_t raw_x, uint16_t raw_y, int *sx, int *sy) {
*sx = map_clamped((int)raw_y, s_cal.x_min, s_cal.x_max,
s_cal.invert_x ? (SCREEN_W - 1) : 0,
s_cal.invert_x ? 0 : (SCREEN_W - 1));
*sy = map_clamped((int)raw_x, s_cal.y_min, s_cal.y_max,
s_cal.invert_y ? (SCREEN_H - 1) : 0,
s_cal.invert_y ? 0 : (SCREEN_H - 1));
}
static void draw_crosshair(int x, int y, uint16_t color) {
// Use fillRect (known-good from the corner test) instead of drawPixel,
// which appeared to misposition crosshairs near the right/bottom edges.
const int arm = 12;
// horizontal arm: a 1-pixel-tall line from x-arm to x+arm at y
int x0 = x - arm, x1 = x + arm;
if (x0 < 0) x0 = 0;
if (x1 >= SCREEN_W) x1 = SCREEN_W - 1;
tft.fillRect(x0, y, x1 - x0 + 1, 1, color);
// vertical arm: a 1-pixel-wide line from y-arm to y+arm at x
int y0 = y - arm, y1 = y + arm;
if (y0 < 0) y0 = 0;
if (y1 >= SCREEN_H) y1 = SCREEN_H - 1;
tft.fillRect(x, y0, 1, y1 - y0 + 1, color);
// small center dot so the tap target is obvious
tft.fillRect(x - 1, y - 1, 3, 3, color);
}
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 4000) ;
pinMode(LED_BUILTIN, OUTPUT);
// Backlight on
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, HIGH);
// Display init (portrait 320x480). T_IRQ is on pin 2 (NOT pin 10, which is
// SPI0 CS0 and corrupts the SPI engine if pinMode'd), so we can safely set
// up the touch pins after init.
// init() with the *native* dimensions (320x480) + setRotation(1) gives
// landscape 480x320 with zero offsets. Don't use init(480,320) — that hits
// the library's else-branch which computes a negative colstart and offsets
// the image off the panel. SCREEN_W/SCREEN_H (480x320) are for drawing
// coordinates only, not for init().
tft.init(320, 480);
tft.setRotation(1);
Serial.print("Display: width="); Serial.print(tft.width());
Serial.print(" height="); Serial.println(tft.height());
tft.fillScreen(0x0000);
// Touch CS idle high, IRQ is input (XPT2046 drives it low on press).
pinMode(T_CS, OUTPUT);
digitalWrite(T_CS, HIGH);
pinMode(T_IRQ, INPUT);
// ---- Diagnostic: draw the white border, all 4 colored corner squares, AND
// all 4 calibration crosshairs at once, with labels, so we can see exactly
// where the crosshairs land relative to the known-correct corner squares.
// Leave it up until a byte is received.
{
// Use the constants, not tft.width()/tft.height() — the accessors return
// the unrotated 480x320 dimensions after setRotation(1).
int w = SCREEN_W;
int h = SCREEN_H;
const int margin = 20;
Serial.print("Drawing border "); Serial.print(w); Serial.print("x"); Serial.println(h);
Serial.print("Crosshair targets (margin="); Serial.print(margin); Serial.println("):");
Serial.print(" TL ("); Serial.print(margin); Serial.print(","); Serial.print(margin); Serial.println(")");
Serial.print(" TR ("); Serial.print(w - margin); Serial.print(","); Serial.print(margin); Serial.println(")");
Serial.print(" BL ("); Serial.print(margin); Serial.print(","); Serial.print(h - margin); Serial.println(")");
Serial.print(" BR ("); Serial.print(w - margin); Serial.print(","); Serial.print(h - margin); Serial.println(")");
tft.fillScreen(0x0000);
tft.drawRect(0, 0, w, h, 0xFFFF); // white border
// 4 colored corner squares (known-good from the earlier test)
tft.fillRect(0, 0, 10, 10, 0xF800); // red TL
tft.fillRect(w - 10, 0, 10, 10, 0x07E0); // green TR
tft.fillRect(0, h - 10, 10, 10, 0x001F); // blue BL
tft.fillRect(w - 10, h - 10, 10, 10, 0xFFE0); // yellow BR
// 4 white crosshairs at the calibration target positions
draw_crosshair(margin, margin, 0xFFFF); // TL
draw_crosshair(w - margin, margin, 0xFFFF); // TR
draw_crosshair(margin, h - margin, 0xFFFF); // BL
draw_crosshair(w - margin, h - margin, 0xFFFF); // BR
// labels
tft.setTextColor(0xFFFF, 0x0000);
tft.setTextSize(2);
tft.setCursor(40, 4);
tft.print("TL");
tft.setCursor(w - 60, 4);
tft.print("TR");
tft.setCursor(40, h - 20);
tft.print("BL");
tft.setCursor(w - 60, h - 20);
tft.print("BR");
tft.setTextSize(1);
tft.setCursor(10, h / 2 - 10);
tft.print("White + = crosshair targets");
tft.setCursor(10, h / 2 + 4);
tft.print("Colored sq = (0,0)/(w,0)/(0,h)/(w,h)");
tft.setCursor(10, h / 2 + 18);
tft.print("Send byte to start calibration");
Serial.println("Diagnostic on screen. Send any byte to start calibration.");
while (!Serial.available()) { }
while (Serial.available()) Serial.read();
}
Serial.println("XPT2046 touch calibration. Tap the 4 crosshairs as they appear.");
run_calibration();
// Live mode: draw a dot wherever the stylus touches.
tft.fillScreen(0x0000);
tft.setTextColor(0xFFFF, 0x0000);
tft.setTextSize(2);
tft.setCursor(10, 10);
tft.print("Live mode: draw with stylus");
tft.setCursor(10, 40);
tft.print("Send any byte to re-calibrate");
Serial.println("Live mode: draw with the stylus. Send any byte to re-calibrate.");
}
void loop() {
// Re-calibrate on any incoming byte
if (Serial.available()) {
while (Serial.available()) Serial.read();
run_calibration();
tft.fillScreen(0x0000);
tft.setTextColor(0xFFFF, 0x0000);
tft.setTextSize(2);
tft.setCursor(10, 10);
tft.print("Live mode: draw with stylus");
tft.setCursor(10, 40);
tft.print("Send any byte to re-calibrate");
}
uint16_t rx, ry, rz;
if (touch_read_raw(&rx, &ry, &rz)) {
int sx, sy;
raw_to_screen(rx, ry, &sx, &sy);
// Draw a small filled circle (3x3) at the calibrated point
tft.fillRect(sx - 1, sy - 1, 3, 3, 0xF800); // red dot
Serial.print("raw x="); Serial.print(rx);
Serial.print(" y="); Serial.print(ry);
Serial.print(" z="); Serial.print(rz);
Serial.print(" -> screen ("); Serial.print(sx);
Serial.print(","); Serial.print(sy); Serial.println(")");
delay(20); // light throttle so dots don't smear too fast
}
}