Skip to content

Instantly share code, notes, and snippets.

@ARDev1161
Last active December 25, 2025 03:47
Show Gist options
  • Select an option

  • Save ARDev1161/d038180d2ade3861de837eabcc482a81 to your computer and use it in GitHub Desktop.

Select an option

Save ARDev1161/d038180d2ade3861de837eabcc482a81 to your computer and use it in GitHub Desktop.
Lilygo T-QT Pro + ENS160 + AHT21
/*
LilyGO T-QT Pro (ESP32-S3FN4R2) + GC9107 128x128 (TFT_eSPI)
ENS160 + AHT21 on I2C
UI:
- Black background, cyan text normally
- If "BAD" (poor): black bg, RED text
- If "DANGER" (harmful): once per second for 420ms bg turns ORANGE and the *cause* is shown in RED
Buttons (T-QT Pro):
- IO0 : short press -> next screen
- IO47 : long press -> restart ENS160 (IDLE->STANDARD)
*/
#include <Wire.h>
#include <TFT_eSPI.h>
static const uint16_t screenWidth = 128;
static const uint16_t screenHeight = 128;
TFT_eSPI tft = TFT_eSPI(screenWidth, screenHeight);
// -------------------- T-QT Pro pins --------------------
// Buttons
static constexpr int BTN_LEFT = 0; // IO0
static constexpr int BTN_RIGHT = 47; // IO47
// I2C (Qwiic/STEMMA on many T-QT Pro boards)
static constexpr int I2C_SDA = 43;
static constexpr int I2C_SCL = 44;
// -------------------- Colors --------------------
#define COLOR_BG_NORMAL TFT_BLACK
#define COLOR_TEXT_NORMAL TFT_CYAN
#define COLOR_TEXT_BAD TFT_RED
#define COLOR_BG_DANGER TFT_ORANGE
#define COLOR_TEXT_DANGER TFT_RED
// -------------------- Alert timing --------------------
static constexpr uint32_t ALERT_PERIOD_MS = 1000;
static constexpr uint32_t ALERT_ON_MS = 420;
// -------------------- Living space thresholds --------------------
// BAD: text red; DANGER: flashing orange bg + red cause text
static constexpr uint16_t ECO2_BAD = 1200;
static constexpr uint16_t ECO2_DANGER = 1500;
static constexpr uint16_t TVOC_BAD = 600;
static constexpr uint16_t TVOC_DANGER = 1000;
static constexpr uint8_t AQI_BAD = 3; // 1..5
static constexpr uint8_t AQI_DANGER = 4;
// -------------------- Screen timeout --------------------
static constexpr uint32_t SCREEN_ON_MS = 7000; // keep screen on for 7s after activity
static constexpr uint32_t OFF_MONITOR_MS = 1000; // while screen off, check sensors every 1s
static bool displayOn = true;
static uint32_t lastActivityMs = 0;
static uint32_t lastOffCheckMs = 0;
// -------------------- I2C addresses --------------------
static constexpr uint8_t AHT21_ADDR = 0x38;
static constexpr uint8_t ENS160_ADDR0 = 0x52; // AD0=0
static constexpr uint8_t ENS160_ADDR1 = 0x53; // AD0=1
static uint8_t ENS_ADDR = 0;
// -------------------- ENS160 registers --------------------
static constexpr uint8_t ENS_OPMODE = 0x10;
static constexpr uint8_t ENS_TEMP_IN = 0x13;
static constexpr uint8_t ENS_RH_IN = 0x15;
static constexpr uint8_t ENS_STATUS = 0x20;
static constexpr uint8_t ENS_AQI = 0x21;
static constexpr uint8_t ENS_TVOC = 0x22;
static constexpr uint8_t ENS_ECO2 = 0x24;
static constexpr uint8_t ENS_IDLE = 0x01;
static constexpr uint8_t ENS_STANDARD = 0x02;
// -------------------- State --------------------
enum AirState { AIR_OK, AIR_BAD, AIR_DANGER };
enum AirCause { CAUSE_NONE, CAUSE_ECO2, CAUSE_TVOC, CAUSE_AQI };
static uint8_t screen = 0;
static uint32_t lastBtnMs = 0;
static AirState lastState = AIR_OK;
// -------------------- I2C helpers --------------------
static bool i2cPing(uint8_t addr) {
Wire.beginTransmission(addr);
return (Wire.endTransmission() == 0);
}
static bool i2cRead(uint8_t addr, uint8_t reg, uint8_t* out, size_t n) {
Wire.beginTransmission(addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false;
size_t got = Wire.requestFrom((int)addr, (int)n);
if (got != n) return false;
for (size_t i = 0; i < n; i++) out[i] = Wire.read();
return true;
}
static bool i2cWrite(uint8_t addr, uint8_t reg, const uint8_t* data, size_t n) {
Wire.beginTransmission(addr);
Wire.write(reg);
for (size_t i = 0; i < n; i++) Wire.write(data[i]);
return (Wire.endTransmission() == 0);
}
static bool i2cWriteByte(uint8_t addr, uint8_t reg, uint8_t v) {
return i2cWrite(addr, reg, &v, 1);
}
// -------------------- AHT21 read --------------------
static bool aht21Read(float& tempC, float& rh) {
// Trigger measurement: 0xAC 0x33 0x00
Wire.beginTransmission(AHT21_ADDR);
Wire.write(0xAC);
Wire.write(0x33);
Wire.write(0x00);
if (Wire.endTransmission() != 0) return false;
delay(85);
Wire.requestFrom((int)AHT21_ADDR, 6);
if (Wire.available() != 6) return false;
uint8_t d[6];
for (int i = 0; i < 6; i++) d[i] = Wire.read();
// Busy bit (bit7) - if still measuring
if (d[0] & 0x80) return false;
uint32_t rawHum = ((uint32_t)d[1] << 12) | ((uint32_t)d[2] << 4) | ((d[3] >> 4) & 0x0F);
uint32_t rawTmp = ((uint32_t)(d[3] & 0x0F) << 16) | ((uint32_t)d[4] << 8) | d[5];
rh = (rawHum * 100.0f) / 1048576.0f; // 2^20
tempC = ((rawTmp * 200.0f) / 1048576.0f) - 50.0f;
return true;
}
// -------------------- ENS160 --------------------
static bool ens160Detect() {
if (i2cPing(ENS160_ADDR0)) { ENS_ADDR = ENS160_ADDR0; return true; }
if (i2cPing(ENS160_ADDR1)) { ENS_ADDR = ENS160_ADDR1; return true; }
return false;
}
static void ens160Restart() {
if (!ENS_ADDR) return;
i2cWriteByte(ENS_ADDR, ENS_OPMODE, ENS_IDLE);
delay(50);
i2cWriteByte(ENS_ADDR, ENS_OPMODE, ENS_STANDARD);
}
static void ens160Compensate(float tempC, float rh) {
if (!ENS_ADDR) return;
// TEMP_IN = (C + 273.15) * 64
uint16_t t_in = (uint16_t)((tempC + 273.15f) * 64.0f + 0.5f);
// RH_IN = %RH * 512
if (rh < 0) rh = 0;
if (rh > 100) rh = 100;
uint16_t rh_in = (uint16_t)(rh * 512.0f + 0.5f);
uint8_t tb[2] = { (uint8_t)(t_in & 0xFF), (uint8_t)(t_in >> 8) };
uint8_t hb[2] = { (uint8_t)(rh_in & 0xFF), (uint8_t)(rh_in >> 8) };
i2cWrite(ENS_ADDR, ENS_TEMP_IN, tb, 2);
i2cWrite(ENS_ADDR, ENS_RH_IN, hb, 2);
}
static bool ens160Read(uint8_t& aqi, uint16_t& tvoc, uint16_t& eco2, uint8_t& status) {
if (!ENS_ADDR) return false;
if (!i2cRead(ENS_ADDR, ENS_STATUS, &status, 1)) return false;
uint8_t a;
if (!i2cRead(ENS_ADDR, ENS_AQI, &a, 1)) return false;
aqi = (a & 0x07); // 1..5
uint8_t b[2];
if (!i2cRead(ENS_ADDR, ENS_TVOC, b, 2)) return false;
tvoc = (uint16_t)b[0] | ((uint16_t)b[1] << 8);
if (!i2cRead(ENS_ADDR, ENS_ECO2, b, 2)) return false;
eco2 = (uint16_t)b[0] | ((uint16_t)b[1] << 8);
return true;
}
// -------------------- Air state logic --------------------
static AirState detectAirState(uint8_t aqi, uint16_t tvoc, uint16_t eco2, AirCause& cause) {
// DANGER first
if (eco2 >= ECO2_DANGER) { cause = CAUSE_ECO2; return AIR_DANGER; }
if (tvoc >= TVOC_DANGER) { cause = CAUSE_TVOC; return AIR_DANGER; }
if (aqi >= AQI_DANGER) { cause = CAUSE_AQI; return AIR_DANGER; }
// BAD
if (eco2 >= ECO2_BAD) { cause = CAUSE_ECO2; return AIR_BAD; }
if (tvoc >= TVOC_BAD) { cause = CAUSE_TVOC; return AIR_BAD; }
if (aqi >= AQI_BAD) { cause = CAUSE_AQI; return AIR_BAD; }
cause = CAUSE_NONE;
return AIR_OK;
}
static bool alertPhase() {
return (millis() % ALERT_PERIOD_MS) < ALERT_ON_MS;
}
static bool btnPressed(int pin) { return digitalRead(pin) == LOW; }
// -------------------- Screen power helpers --------------------
static void screenWake() {
if (!displayOn) {
digitalWrite(TFT_BL, LOW);
tft.writecommand(0x29); // Display ON
displayOn = true;
}
lastActivityMs = millis();
}
static void screenSleep() {
if (displayOn) {
tft.writecommand(0x28); // Display OFF
digitalWrite(TFT_BL, HIGH);
displayOn = false;
}
}
// -------------------- UI helpers --------------------
static void uiHeader() {
tft.setTextSize(1);
tft.setCursor(0, 0);
}
static void drawScreen0(float tC, float rh, uint8_t aqi, uint16_t tvoc, uint16_t eco2,
bool okAHT, bool okENS, AirState st, AirCause cause) {
(void)cause;
tft.fillScreen(COLOR_BG_NORMAL);
// text color depends on state (BAD -> red, OK -> cyan)
uint16_t txt = (st == AIR_BAD) ? COLOR_TEXT_BAD : COLOR_TEXT_NORMAL;
tft.setTextColor(txt, COLOR_BG_NORMAL);
uiHeader();
tft.setTextSize(2);
tft.setCursor(0, 24);
if (okAHT) {
tft.printf("T %.1fC\n", tC);
tft.printf("H %.0f%%\n", rh);
} else {
tft.println("AHT FAIL");
}
tft.println();
if (okENS) {
tft.printf("AQI %d\n", aqi);
} else {
tft.println("ENS FAIL");
}
tft.setTextSize(1);
tft.setCursor(0, 112);
tft.println("IO0 next | IO47 hold reset");
}
static void drawScreen1(uint8_t aqi, uint16_t tvoc, uint16_t eco2, uint8_t status,
bool okENS, AirState st, AirCause cause) {
(void)cause;
tft.fillScreen(COLOR_BG_NORMAL);
uint16_t txt = (st == AIR_BAD) ? COLOR_TEXT_BAD : COLOR_TEXT_NORMAL;
tft.setTextColor(txt, COLOR_BG_NORMAL);
uiHeader();
tft.setTextSize(2);
tft.setCursor(0, 24);
if (okENS) {
tft.printf("TVOC\n%dppb\n\n", tvoc);
tft.printf("eCO2\n%dppm\n\n", eco2);
} else {
tft.println("ENS FAIL");
}
tft.setTextSize(1);
tft.setCursor(0, 104);
if (okENS) tft.printf("AQI %d status 0x%02X\n", aqi, status);
tft.setCursor(0, 116);
tft.println("R_BTN + L_BTN hold reset");
}
static void drawDanger(AirCause c, uint16_t tvoc, uint16_t eco2, uint8_t aqi) {
tft.fillScreen(COLOR_BG_DANGER);
tft.setTextColor(COLOR_TEXT_DANGER, COLOR_BG_DANGER);
tft.setTextSize(2);
tft.setCursor(12, 14);
tft.println("HARMFUL");
tft.setCursor(12, 46);
switch (c) {
case CAUSE_ECO2:
tft.printf("eCO2\n%d ppm", eco2);
break;
case CAUSE_TVOC:
tft.printf("TVOC\n%d ppb", tvoc);
break;
case CAUSE_AQI:
tft.printf("AQI\n%d", aqi);
break;
default:
tft.println("AIR");
break;
}
tft.setTextSize(1);
tft.setCursor(10, 116);
tft.println("Ventilate / open window");
}
static constexpr uint8_t MADCTL_MY = 0x80;
static constexpr uint8_t MADCTL_MX = 0x40;
static constexpr uint8_t MADCTL_MV = 0x20;
static constexpr uint8_t MADCTL_BGR = 0x08;
// -------------------- Display orientation (your working fix) --------------------
static uint8_t madctl_base_for_rot(uint8_t rot) {
switch (rot & 3) {
case 0: return 0;
case 1: return MADCTL_MV | MADCTL_MX;
case 2: return MADCTL_MX | MADCTL_MY;
case 3: return MADCTL_MV | MADCTL_MY;
}
return 0;
}
static void tft_apply_madctl(uint8_t madctl) {
tft.writecommand(0x36);
tft.writedata(madctl);
}
// -------------------- Setup / Loop --------------------
void setup() {
Serial.begin(115200);
delay(150);
pinMode(BTN_LEFT, INPUT_PULLUP);
pinMode(BTN_RIGHT, INPUT_PULLUP);
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, HIGH);
displayOn = true;
lastActivityMs = millis();
lastOffCheckMs = 0;
// I2C init; if sensors are not found, try swapping SDA/SCL
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(400000);
tft.init();
tft.setRotation(0);
tft.invertDisplay(true);
tft_apply_madctl(MADCTL_MV | MADCTL_MX | MADCTL_BGR);
tft.fillScreen(COLOR_BG_NORMAL);
tft.setTextColor(COLOR_TEXT_NORMAL, COLOR_BG_NORMAL);
tft.setTextSize(1);
tft.setCursor(0, 0);
tft.println("Boot...");
if (!ens160Detect()) {
tft.println("ENS160 not found");
tft.println("Try swap SDA/SCL");
Serial.println("ENS160 not found on 0x52/0x53");
} else {
Serial.printf("ENS160 at 0x%02X\n", ENS_ADDR);
ens160Restart();
}
}
void loop() {
if (btnPressed(BTN_LEFT) || btnPressed(BTN_RIGHT)) {
screenWake();
}
// Button: IO0 short press -> next screen
if (btnPressed(BTN_LEFT) && (millis() - lastBtnMs) > 250) {
screen ^= 1;
lastBtnMs = millis();
lastActivityMs = millis();
}
// Button: IO47 long press -> restart ENS160
static uint32_t rightDownMs = 0;
if (btnPressed(BTN_RIGHT)) {
if (rightDownMs == 0) rightDownMs = millis();
if ((millis() - rightDownMs) > 1200 && (millis() - lastBtnMs) > 1200) {
ens160Restart();
lastBtnMs = millis();
lastActivityMs = millis();
}
} else {
rightDownMs = 0;
}
// If display is OFF: check sensors lightly every OFF_MONITOR_MS, wake on AIR_DANGER
if (!displayOn) {
if (millis() - lastOffCheckMs < OFF_MONITOR_MS) {
delay(20);
return;
}
lastOffCheckMs = millis();
float tC = NAN, rh = NAN;
bool okAHT = aht21Read(tC, rh);
if (okAHT) ens160Compensate(tC, rh);
uint8_t aqi = 0, status = 0;
uint16_t tvoc = 0, eco2 = 0;
bool okENS = ens160Read(aqi, tvoc, eco2, status);
AirCause cause;
AirState st = okENS ? detectAirState(aqi, tvoc, eco2, cause) : AIR_OK;
// wake ONLY on transition to danger (optional but nice)
if (st == AIR_DANGER && lastState != AIR_DANGER) {
screenWake();
}
lastState = st;
delay(20);
return;
}
// Read sensors (normal mode)
float tC = NAN, rh = NAN;
bool okAHT = aht21Read(tC, rh);
if (okAHT) ens160Compensate(tC, rh);
uint8_t aqi = 0, status = 0;
uint16_t tvoc = 0, eco2 = 0;
bool okENS = ens160Read(aqi, tvoc, eco2, status);
// Determine air state
AirCause cause;
AirState st = detectAirState(aqi, tvoc, eco2, cause);
lastState = st;
// If danger, keep screen alive (do not auto-off)
if (st == AIR_DANGER) {
lastActivityMs = millis();
}
// Auto-off after inactivity (only when not danger)
if (st != AIR_DANGER && (millis() - lastActivityMs) > SCREEN_ON_MS) {
screenSleep();
delay(20);
return;
}
// Draw
if (st == AIR_DANGER && alertPhase()) {
// flashing harmful indication (orange bg, red text)
drawDanger(cause, tvoc, eco2, aqi);
} else {
// normal UI (black bg; cyan or red text if "BAD")
if (screen == 0) {
drawScreen0(tC, rh, aqi, tvoc, eco2, okAHT, okENS, st, cause);
} else {
drawScreen1(aqi, tvoc, eco2, status, okENS, st, cause);
}
}
delay(250);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment