Last active
December 25, 2025 03:47
-
-
Save ARDev1161/d038180d2ade3861de837eabcc482a81 to your computer and use it in GitHub Desktop.
Lilygo T-QT Pro + ENS160 + AHT21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| 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