obd2_gauge/sketch/obd2_gauge/meter.h
Sebastian Nieto 63a7f8161a feat(ui): transicion fundido a negro 200ms, temas de color y correccion brillo LDR
- Implementa transicion 'Fundido a Negro' (Fade to Black) con atenuacion PWM de ~200ms por curva gamma en reemplazo de deslizamiento.
- Corrige el brillo automatico LDR: umbral calibrado a 1000 para ADC de 12 bits, ejecucion continua en el loop sin depender de Bluetooth y telemetria en tiempo real en ajustes.
- Agrega nuevos temas de color: Yapa (pastillas pastel multicolor por caracter), Rojo Corsa y Ambar BMW.
- Integra efecto arcoiris continuo en menus de configuracion y pantallas completas.
- Unifica tipografia de telemetria GPS y renderizado con antialiasing para coordenadas y valores.
2026-10-13 22:09:37 -03:00

2854 lines
95 KiB
C

// All Meters display function
#include "DSEG7_Modern_BoldItalic_24.h"
#include "DSEG7_Modern_BoldItalic_34.h"
#include "DSEG7_Modern_BoldItalic_44.h"
#include "DSEG7_Modern_BoldItalic_80.h"
#include "Latin_Sans_Bold_26.h"
#include "rainbow_text.h"
extern TFT_eSprite sprTrans;
extern bool sprTransAllocated;
extern TFT_eSprite sprFadeB;
extern bool sprFadeBAllocated;
extern uint8_t targetBacklight;
extern float currentBacklight;
const uint16_t screenWidth = tft.height(); // landscape
const uint16_t screenHeight = tft.width();
/*---- METER ------_*
Type 0:numericMeter
Type 1:arcMeters
Type 2:vBarMeter
coordinate for each cell (numieric meter & arc meter)
1 , 4 , 7
2 , 5 , 8
3 , 6 , 9 */
const uint16_t cell1_x[9] = {
0, 0, 0, 80, 80, 80, 160, 160, 160}; // cell type 1 x origin cooridate
const uint16_t cell1_y[9] = {
0, 80, 160, 0, 80, 160, 0, 80, 160}; // cell type 1 y origin coordiate
/*coordinate for each cell type 2 (Vertical bar meter)
1 , 2 , 3 , 4 */
const uint16_t cell2_x[4] = {0, 80, 160,
240}; // vertical bar cell x origin coordinate
const uint16_t cell4_x[4] = {
0, 160, 0, 160}; // 4 quadrant x: top-left, top-right, bot-left, bot-right
const uint16_t cell4_y[4] = {
0, 0, 120, 120}; // 4 quadrant y: top-left, top-right, bot-left, bot-right
bool blinkCell[9] = {false, false, false, false, false,
false, false, false, false}; // flag for blinking
String pidList[maxpidIndex] =
{}; //{"0104","0105","010C","010B","010C","0142","015C"}
uint8_t pidReadSkip[maxpidIndex] = {}; // skip reading 0 - 3; 3 = max delay read
uint8_t pidCurrentSkip[maxpidIndex] = {}; // current skip counter
uint8_t engine_off_count = 0; // counter to indicate engine is off then sleep
float old_data[maxpidIndex] = {-999, -999, -999, -999, -999}; // keep old data
float last_valid_data[maxpidIndex] = {
-999, -999, -999, -999, -999}; // keep last valid data for instant redraw
float currentRpm = 0.0f; // Current engine RPM for sleep/inactivity tracking
float currentEngineLoad = 0.0f; // Current Engine Load % (PID 0104)
bool isEcoOptimal = false; // Flag: RPM 1900-2550 & Load 45-82%
bool badgeEcoDrawn = false; // Tracking rendered status on display
extern uint32_t lastActivityTime; // Activity timestamp for auto-sleep
bool ignitionOn =
true; // True if ECU is actively communicating (contacto puesto)
uint8_t consecutiveEcuFails =
0; // Counter for consecutive ECU communication failures
// Arc warning flash (blinking alarm when gauge is in TFT_RED)
bool arcFlashVisible = true;
uint32_t lastArcFlashTime = 0;
bool wasInRedAlarm[4] = {false, false, false, false};
// Color for inactive meter blocks (medium-dark graphite grey, lighter than
// 0x2124 but darker than 0x7BEF)
const uint16_t METER_INACTIVE_COLOR = 0x39E7; // RGB ~ (58, 60, 58)
// backlight variable average counter
uint8_t low_count = 0;
uint8_t high_count = 0;
const float factoryECUOff = 12.5; // default off voltage
float ecu_off_volt = factoryECUOff; // turn engine off if voltage lower
// than 12.4
// Cache theme gradients to ensure 100% bit-for-bit identical background
// restoration
uint16_t cachedThemeColor = 0xFFFF;
uint16_t themeGradQuad[82];
uint16_t themeGradHeader[145];
void checkUpdateThemeGradients() {
uint16_t currentTheme = getThemeBgColor();
if (currentTheme == cachedThemeColor)
return;
cachedThemeColor = currentTheme;
float delta = -255.0f / 82.0f;
float alpha = 255.0f;
uint16_t col = currentTheme;
for (int i = 0; i < 82; i++) {
themeGradQuad[i] = col;
alpha += delta;
col = fastBlend((uint8_t)alpha, currentTheme, (uint16_t)TFT_BLACK);
}
delta = -255.0f / 145.0f;
alpha = 255.0f;
col = currentTheme;
for (int i = 0; i < 145; i++) {
themeGradHeader[i] = col;
alpha += delta;
col = fastBlend((uint8_t)alpha, currentTheme, (uint16_t)TFT_BLACK);
}
}
inline uint16_t getInstrumentBgColor(int y, bool isQuad, int ry = 0) {
checkUpdateThemeGradients();
if (isQuad) {
int k = y - ry;
if (k < 0)
return themeGradQuad[0];
if (k >= 82)
return TFT_BLACK;
return themeGradQuad[k];
} else {
if (y < 0)
return themeGradHeader[0];
if (y >= 145)
return TFT_BLACK;
return themeGradHeader[y];
}
}
// Shared line/block buffer for fast SPI push (up to 64x64 max icon size, ~8KB
// static RAM)
static uint16_t s_iconRenderBuf[64 * 64];
// Draw icon with true 8-bit alpha channel blended dynamically against current
// instrument background
void drawIconWithAlpha(int x, int y, int w, int h, const unsigned short *colors,
const unsigned char *alphas, bool isQuad, int ry = 0) {
if (w * h > 64 * 64)
return;
for (int j = 0; j < h; j++) {
int curY = y + j;
uint16_t bgCol = getInstrumentBgColor(curY, isQuad, ry);
int rowOffset = j * w;
for (int i = 0; i < w; i++) {
uint8_t a = pgm_read_byte(&alphas[rowOffset + i]);
if (a == 0) {
s_iconRenderBuf[rowOffset + i] = bgCol;
} else if (a >= 250) {
s_iconRenderBuf[rowOffset + i] = pgm_read_word(&colors[rowOffset + i]);
} else {
uint16_t fg = pgm_read_word(&colors[rowOffset + i]);
s_iconRenderBuf[rowOffset + i] = fastBlend(a, fg, bgCol);
}
}
}
tft.setSwapBytes(true);
tft.pushImage(x, y, w, h, s_iconRenderBuf);
}
// Draw parameter icon for 4-quadrant view with true alpha channel
void drawPidIcon(int x, int y, const String &pidStr) {
int ry = (y >= 120) ? 121 : 0;
if (pidStr == "0105") {
drawIconWithAlpha(x, y, 38, 37, icon_temp_quad, icon_temp_quad_alpha, true,
ry);
} else if (pidStr == "010D") {
drawIconWithAlpha(x, y, 42, 42, icon_speed_quad, icon_speed_quad_alpha,
true, ry);
} else if (pidStr == "010C") {
drawIconWithAlpha(x, y, 42, 43, icon_rpm_quad, icon_rpm_quad_alpha, true,
ry);
} else if (pidStr == "0142" || pidStr == "ATRV") {
drawIconWithAlpha(x, y, 46, 35, icon_battery_quad, icon_battery_quad_alpha,
true, ry);
}
}
// Clear parameter icon for 4-quadrant view by restoring exact background lines
// (100% invisible restoration)
void clearPidIcon(int x, int y, const String &pidStr) {
int w = 42;
int h = 42;
if (pidStr == "0105") {
w = 38;
h = 37;
} else if (pidStr == "010D") {
w = 42;
h = 42;
} else if (pidStr == "010C") {
w = 42;
h = 43;
} else if (pidStr == "0142" || pidStr == "ATRV") {
w = 46;
h = 35;
}
int ry = (y >= 120) ? 121 : 0;
for (int j = 0; j < h; j++) {
int curY = y + j;
uint16_t bgCol = getInstrumentBgColor(curY, true, ry);
int rowOffset = j * w;
for (int i = 0; i < w; i++) {
s_iconRenderBuf[rowOffset + i] = bgCol;
}
}
tft.setSwapBytes(true);
tft.pushImage(x, y, w, h, s_iconRenderBuf);
}
// Draw header icon for full screen mode with true alpha channel
void drawPidIconHeader(int x, int y, const String &pidStr) {
if (pidStr == "0105") {
drawIconWithAlpha(8, 2, 48, 48, icon_temp_48, icon_temp_48_alpha, false);
} else if (pidStr == "010D") {
drawIconWithAlpha(8, 2, 48, 48, icon_speed_48, icon_speed_48_alpha, false);
} else if (pidStr == "010C") {
drawIconWithAlpha(11, 4, 42, 43, icon_rpm_quad, icon_rpm_quad_alpha, false);
} else if (pidStr == "0142" || pidStr == "ATRV") {
drawIconWithAlpha(8, 2, 48, 48, icon_battery_48, icon_battery_48_alpha,
false);
}
}
// Restore ambient vignette background over the header icon area during alarm
// blink OFF phase (100% invisible)
void clearPidIconHeader(int x = 8, int y = 2, int w = 50, int h = 52) {
for (int j = 0; j < h; j++) {
int curY = y + j;
uint16_t bgCol = getInstrumentBgColor(curY, false);
int rowOffset = j * w;
for (int i = 0; i < w; i++) {
s_iconRenderBuf[rowOffset + i] = bgCol;
}
}
tft.setSwapBytes(true);
tft.pushImage(x, y, w, h, s_iconRenderBuf);
}
// Draw or clear full screen instrument title during normal display and alarm
// blinking
void drawFullScreenTitle(const String &label, uint16_t color) {
checkUpdateThemeGradients();
for (int y = 10; y <= 40; y++) {
uint16_t bgCol = getInstrumentBgColor(y, false);
tft.drawFastHLine(60, y, 195, bgCol);
}
if (color != 0) {
if (color == TFT_WHITE && isYapaTheme()) {
drawYapaCentreString(label, 175, 12, 4);
} else if (color == TFT_WHITE && isRainbowTheme()) {
drawRainbowCentreString(label.c_str(), 175, 12, 4);
} else {
tft.setTextColor(color);
tft.drawCentreString(label.c_str(), 175, 12, 4);
}
}
}
// Tactical ECO / OPT Badge (Ford Ka Endura 1.3 Sweet Spot: 1900 - 2550 RPM &
// 45% - 82% Load)
void updateEcoBadge(bool forceRedraw = false) {
// CRITICAL: ECO badge ONLY belongs on Page 0 (RPM quadrant or fullscreen
// RPM), NEVER on Page 1 (Tri-Temp)!
if (currentScreenPage != 0) {
badgeEcoDrawn = false;
return;
}
// If in fullscreen on Page 0, only display if the fullscreen cell is RPM
bool isQuad = (fullScreenCell < 0 || fullScreenCell >= 4);
if (!isQuad && pidConfig[pidInCell[fullScreenCell]][3].toInt() != 3) {
badgeEcoDrawn = false;
return;
}
bool newEco = (ignitionOn && currentRpm >= 1900.0f && currentRpm <= 2550.0f &&
currentEngineLoad >= 45.0f && currentEngineLoad <= 82.0f);
isEcoOptimal = newEco;
if (forceRedraw) {
badgeEcoDrawn = !newEco; // Force redraw/re-erase
}
if (newEco == badgeEcoDrawn) {
return;
}
int bx = 0, by = 0, bw = 0, bh = 0;
if (isQuad) {
// 4-Quadrant View: Positioned in top-right of RPM Quadrant (Cell 2)
int rpmCell = 2;
for (int c = 0; c < 4; c++) {
if (pidConfig[pidInCell[c]][3].toInt() == 3) {
rpmCell = c;
break;
}
}
bx = cell4_x[rpmCell] + 100;
by = cell4_y[rpmCell] + 4;
bw = 48;
bh = 18;
} else {
// Full-Screen View: Positioned in top-right header band
bx = 260;
by = 11;
bw = 50;
bh = 20;
}
if (newEco) {
// Elegant tactical pill badge [ ECO ]
const uint16_t bgFill = 0x0202; // Deep dark forest emerald green
const uint16_t borderCol = 0x07E0; // Vibrant emerald neon green
tft.fillRoundRect(bx, by, bw, bh, 4, bgFill);
tft.drawRoundRect(bx, by, bw, bh, 4, borderCol);
tft.setTextColor(TFT_WHITE, bgFill);
if (isQuad) {
tft.drawCentreString("ECO", bx + (bw / 2), by + 1, 2);
} else {
tft.drawCentreString("ECO", bx + (bw / 2), by + 2, 2);
}
} else {
// Cleanly restore background gradient lines with zero visual trace
int ry = (isQuad && by >= 120) ? 121 : 0;
for (int y = by; y < by + bh; y++) {
uint16_t bgCol = getInstrumentBgColor(y, isQuad, ry);
tft.drawFastHLine(bx, y, bw, bgCol);
}
}
badgeEcoDrawn = newEco;
}
// Full Screen Exponential Segmented Block Bar Graph (20 blocks: 3mm width x 4mm
// height, 0.5mm gap, Anti-Aliased)
const uint8_t NUM_BAR_BLOCKS = 20;
const int16_t blockMinX[20] PROGMEM = {0, 5, 11, 17, 25, 34, 46,
59, 76, 95, 114, 134, 154, 174,
193, 213, 232, 251, 271, 290};
const int16_t blockMaxX[20] PROGMEM = {28, 34, 40, 47, 55, 65, 76,
89, 104, 121, 138, 157, 175, 194,
214, 233, 252, 271, 290, 310};
const int16_t blockMinY[20] PROGMEM = {202, 183, 164, 146, 127, 110, 94,
80, 70, 63, 59, 56, 54, 53,
52, 52, 52, 52, 52, 52};
const int16_t blockMaxY[20] PROGMEM = {226, 208, 190, 172, 155, 139, 124,
111, 100, 92, 87, 83, 80, 79,
78, 77, 77, 77, 77, 77};
const float blockNx[20][4] PROGMEM = {
{0.96709f, -0.26991f, -0.96711f, 0.24260f},
{0.95775f, -0.30696f, -0.95776f, 0.26991f},
{0.94416f, -0.35424f, -0.94418f, 0.30696f},
{0.92359f, -0.41544f, -0.92365f, 0.35424f},
{0.89126f, -0.49508f, -0.89141f, 0.41544f},
{0.83905f, -0.59638f, -0.83936f, 0.49508f},
{0.75533f, -0.71496f, -0.75581f, 0.59638f},
{0.63154f, -0.83065f, -0.63179f, 0.71496f},
{0.47873f, -0.91631f, -0.47824f, 0.83065f},
{0.33039f, -0.96398f, -0.32962f, 0.91631f},
{0.21408f, -0.98563f, -0.21356f, 0.96398f},
{0.13436f, -0.99446f, -0.13412f, 0.98563f},
{0.08319f, -0.99789f, -0.08309f, 0.99446f},
{0.05123f, -0.99920f, -0.05120f, 0.99789f},
{0.03149f, -0.99970f, -0.03148f, 0.99920f},
{0.01934f, -0.99989f, -0.01934f, 0.99970f},
{0.01188f, -0.99996f, -0.01187f, 0.99989f},
{0.00729f, -0.99998f, -0.00729f, 0.99996f},
{0.00448f, -0.99999f, -0.00448f, 0.99998f},
{0.00275f, -1.00000f, -0.00275f, 0.99999f},
};
const float blockNy[20][4] PROGMEM = {
{0.25444f, 0.96289f, -0.25437f, -0.97013f},
{0.28762f, 0.95172f, -0.28757f, -0.96289f},
{0.32950f, 0.93516f, -0.32942f, -0.95172f},
{0.38338f, 0.90962f, -0.38323f, -0.93516f},
{0.45348f, 0.86885f, -0.45320f, -0.90962f},
{0.54405f, 0.80270f, -0.54358f, -0.86885f},
{0.65535f, 0.69916f, -0.65480f, -0.80270f},
{0.77534f, 0.55679f, -0.77514f, -0.69916f},
{0.87796f, 0.40047f, -0.87823f, -0.55679f},
{0.94384f, 0.26596f, -0.94412f, -0.40047f},
{0.97682f, 0.16890f, -0.97693f, -0.26596f},
{0.99093f, 0.10510f, -0.99097f, -0.16890f},
{0.99653f, 0.06486f, -0.99654f, -0.10510f},
{0.99869f, 0.03990f, -0.99869f, -0.06486f},
{0.99950f, 0.02452f, -0.99950f, -0.03990f},
{0.99981f, 0.01506f, -0.99981f, -0.02452f},
{0.99993f, 0.00924f, -0.99993f, -0.01506f},
{0.99997f, 0.00568f, -0.99997f, -0.00924f},
{0.99999f, 0.00348f, -0.99999f, -0.00568f},
{1.00000f, 0.00223f, -1.00000f, -0.00348f},
};
const float blockC[20][4] PROGMEM = {
{-57.0917f, -194.0217f, 79.0747f, 212.4570f},
{-63.7256f, -171.8745f, 85.7128f, 191.2717f},
{-71.2233f, -148.4987f, 93.2024f, 169.1245f},
{-79.6909f, -123.2275f, 101.6563f, 145.7487f},
{-89.0635f, -95.0180f, 111.0070f, 120.4775f},
{-98.7898f, -62.4921f, 120.7056f, 92.2680f},
{-107.1996f, -24.6792f, 129.0998f, 59.7421f},
{-111.1224f, 16.8839f, 133.0287f, 21.9292f},
{-107.7811f, 57.2365f, 129.6718f, -19.6339f},
{-98.3836f, 92.0853f, 120.2644f, -59.9865f},
{-87.1827f, 121.1162f, 109.0982f, -94.8353f},
{-77.3181f, 146.0701f, 99.2734f, -123.8662f},
{-69.7527f, 168.5916f, 91.7322f, -148.8201f},
{-64.3207f, 189.7308f, 86.3119f, -171.3416f},
{-60.5502f, 210.0867f, 82.5466f, -192.4808f},
{-57.9834f, 229.9930f, 79.9819f, -212.8367f},
{-56.2577f, 249.6371f, 78.2571f, -232.7430f},
{-55.1079f, 269.1261f, 77.1076f, -252.3871f},
{-54.3470f, 288.5222f, 76.3469f, -271.8761f},
{-53.8454f, 307.8565f, 75.8453f, -291.2722f},
};
// Draw an individual solid rectangular block with smooth subpixel anti-aliasing
inline void drawBarBlock(uint8_t i, uint16_t color) {
if (isRainbowTheme() && color != TFT_RED && color != METER_INACTIVE_COLOR && color != TFT_BLACK)
color = getRainbowBlockColor(i, NUM_BAR_BLOCKS);
int16_t minX = pgm_read_word(&blockMinX[i]);
int16_t maxX = pgm_read_word(&blockMaxX[i]);
int16_t minY = pgm_read_word(&blockMinY[i]);
int16_t maxY = pgm_read_word(&blockMaxY[i]);
float nx[4], ny[4], c[4];
for (uint8_t k = 0; k < 4; k++) {
nx[k] = pgm_read_float(&blockNx[i][k]);
ny[k] = pgm_read_float(&blockNy[i][k]);
c[k] = pgm_read_float(&blockC[i][k]);
}
tft.startWrite();
for (int16_t y = minY; y <= maxY; y++) {
float py = (float)y + 0.5f;
for (int16_t x = minX; x <= maxX; x++) {
float px = (float)x + 0.5f;
float min_d = 999.0f;
for (uint8_t k = 0; k < 4; k++) {
float d = px * nx[k] + py * ny[k] + c[k];
if (d < min_d)
min_d = d;
}
if (min_d <= -0.5f)
continue;
if (color == TFT_BLACK) {
tft.drawPixel(x, y, TFT_BLACK);
} else {
if (min_d >= 0.5f) {
tft.drawPixel(x, y, color);
} else {
uint8_t alpha = (uint8_t)((min_d + 0.5f) * 255.0f);
tft.drawPixel(x, y, color, alpha, TFT_BLACK);
}
}
}
}
tft.endWrite();
}
// 4-Quadrant Exponential Segmented Block Bar Graph (20 blocks per 160x120
// quadrant, Anti-Aliased)
const int16_t quadBlockMinX[20] PROGMEM = {5, 8, 12, 15, 20, 24, 30,
37, 45, 53, 62, 71, 80, 89,
98, 107, 117, 126, 135, 144};
const int16_t quadBlockMaxX[20] PROGMEM = {18, 21, 24, 28, 33, 38, 44,
50, 58, 65, 74, 82, 91, 100,
109, 118, 127, 136, 145, 154};
const int16_t quadBlockMinY[20] PROGMEM = {100, 91, 83, 74, 66, 59, 51,
45, 40, 36, 33, 31, 30, 29,
28, 28, 27, 27, 27, 27};
const int16_t quadBlockMaxY[20] PROGMEM = {112, 103, 95, 87, 79, 72, 65,
59, 53, 49, 46, 43, 41, 40,
39, 38, 38, 37, 37, 37};
const float quadBlockNx[20][4] PROGMEM = {
{-0.94779f, -0.31891f, 0.94779f, 0.31891f},
{-0.93472f, -0.35539f, 0.93472f, 0.35539f},
{-0.91662f, -0.39976f, 0.91662f, 0.39976f},
{-0.89115f, -0.45372f, 0.89115f, 0.45372f},
{-0.85466f, -0.51919f, 0.85466f, 0.51919f},
{-0.80234f, -0.59687f, 0.80234f, 0.59687f},
{-0.72909f, -0.68442f, 0.72909f, 0.68442f},
{-0.63267f, -0.77442f, 0.63267f, 0.77442f},
{-0.51899f, -0.85478f, 0.51899f, 0.85478f},
{-0.40261f, -0.91537f, 0.40261f, 0.91537f},
{-0.29860f, -0.95438f, 0.29860f, 0.95438f},
{-0.21498f, -0.97662f, 0.21498f, 0.97662f},
{-0.15215f, -0.98836f, 0.15215f, 0.98836f},
{-0.10673f, -0.99429f, 0.10673f, 0.99429f},
{-0.07452f, -0.99722f, 0.07452f, 0.99722f},
{-0.05192f, -0.99865f, 0.05192f, 0.99865f},
{-0.03612f, -0.99935f, 0.03612f, 0.99935f},
{-0.02513f, -0.99968f, 0.02513f, 0.99968f},
{-0.01747f, -0.99985f, 0.01747f, 0.99985f},
{-0.01214f, -0.99993f, 0.01214f, 0.99993f},
};
const float quadBlockNy[20][4] PROGMEM = {
{-0.31891f, 0.94779f, 0.31891f, -0.94779f},
{-0.35539f, 0.93472f, 0.35539f, -0.93472f},
{-0.39976f, 0.91662f, 0.39976f, -0.91662f},
{-0.45372f, 0.89115f, 0.45372f, -0.89115f},
{-0.51919f, 0.85466f, 0.51919f, -0.85466f},
{-0.59687f, 0.80234f, 0.59687f, -0.80234f},
{-0.68442f, 0.72909f, 0.68442f, -0.72909f},
{-0.77442f, 0.63267f, 0.77442f, -0.63267f},
{-0.85478f, 0.51899f, 0.85478f, -0.51899f},
{-0.91537f, 0.40261f, 0.91537f, -0.40261f},
{-0.95438f, 0.29860f, 0.95438f, -0.29860f},
{-0.97662f, 0.21498f, 0.97662f, -0.21498f},
{-0.98836f, 0.15215f, 0.98836f, -0.15215f},
{-0.99429f, 0.10673f, 0.99429f, -0.10673f},
{-0.99722f, 0.07452f, 0.99722f, -0.07452f},
{-0.99865f, 0.05192f, 0.99865f, -0.05192f},
{-0.99935f, 0.03612f, 0.99935f, -0.03612f},
{-0.99968f, 0.02513f, 0.99968f, -0.02513f},
{-0.99985f, 0.01747f, 0.99985f, -0.01747f},
{-0.99993f, 0.01214f, 0.99993f, -0.01214f},
};
const float quadBlockC[20][4] PROGMEM = {
{49.3731f, -92.9228f, -41.1731f, 100.9228f},
{52.9126f, -82.0215f, -44.7126f, 90.0215f},
{56.7515f, -70.4863f, -48.5515f, 78.4863f},
{60.8192f, -58.1314f, -52.6192f, 66.1314f},
{64.9598f, -44.6227f, -56.7598f, 52.6227f},
{68.8007f, -29.6371f, -60.6007f, 37.6371f},
{71.6789f, -12.9798f, -63.4789f, 20.9798f},
{72.7161f, 5.1281f, -64.5161f, 2.8719f},
{71.2620f, 23.7112f, -63.0620f, -15.7112f},
{67.5191f, 41.3967f, -59.3191f, -33.3967f},
{62.4933f, 57.2743f, -54.2933f, -49.2743f},
{57.2945f, 71.2057f, -49.0945f, -63.2057f},
{52.6178f, 83.5542f, -44.4178f, -75.5542f},
{48.7270f, 94.7806f, -40.5270f, -86.7806f},
{45.6295f, 105.2669f, -37.4295f, -97.2669f},
{43.2291f, 115.2686f, -35.0291f, -107.2686f},
{41.3977f, 124.9666f, -33.1977f, -116.9666f},
{40.0175f, 134.4587f, -31.8175f, -126.4587f},
{38.9847f, 143.8219f, -30.7847f, -135.8219f},
{38.2169f, 153.0966f, -30.0169f, -145.0966f},
};
// Draw an individual solid rectangular block for 4-quadrant view with smooth
// subpixel anti-aliasing
inline void drawQuadBarBlock(uint8_t cell, uint8_t i, uint16_t color) {
// Rainbow theme: active blocks become a spectral gradient
if (isRainbowTheme() && color != TFT_RED && color != METER_INACTIVE_COLOR && color != TFT_BLACK)
color = getRainbowBlockColor(i);
int16_t ox = cell4_x[cell];
int16_t oy = cell4_y[cell];
int16_t minX = pgm_read_word(&quadBlockMinX[i]);
int16_t maxX = pgm_read_word(&quadBlockMaxX[i]);
int16_t minY = pgm_read_word(&quadBlockMinY[i]);
int16_t maxY = pgm_read_word(&quadBlockMaxY[i]);
float nx[4], ny[4], c[4];
for (uint8_t k = 0; k < 4; k++) {
nx[k] = pgm_read_float(&quadBlockNx[i][k]);
ny[k] = pgm_read_float(&quadBlockNy[i][k]);
c[k] = pgm_read_float(&quadBlockC[i][k]);
}
tft.startWrite();
for (int16_t y = minY; y <= maxY; y++) {
float py = (float)y + 0.5f;
int16_t sy = oy + y;
for (int16_t x = minX; x <= maxX; x++) {
float px = (float)x + 0.5f;
float min_d = 999.0f;
for (uint8_t k = 0; k < 4; k++) {
float d = px * nx[k] + py * ny[k] + c[k];
if (d < min_d)
min_d = d;
}
if (min_d <= -0.5f)
continue;
int16_t sx = ox + x;
if (color == TFT_BLACK) {
tft.drawPixel(sx, sy, TFT_BLACK);
} else {
if (min_d >= 0.5f) {
tft.drawPixel(sx, sy, color);
} else {
uint8_t alpha = (uint8_t)((min_d + 0.5f) * 255.0f);
tft.drawPixel(sx, sy, color, alpha, TFT_BLACK);
}
}
}
}
tft.endWrite();
}
/* ####################################
draw digital numeric meter 160x80 px
####################################
cell
1 , 2 , 3
4 , 5 , 6
7 , 8 , 9 */
void numericMeter(uint8_t cell, uint8_t pid) {
int w = screenWidth / 2;
int h = screenHeight / 3;
int x = cell1_x[cell - 1];
int y = cell1_y[cell - 1];
// Array data -> { Label , unit, pid, fomula, min, max, ,skip, digit }
String label = pidConfig[pid][0]; // get PID to label
tft.drawRect(x, y, w, screenHeight, TFT_GREY);
tft.fillRect(x + 2, y + 2, w - 5, h - 5, TFT_BLACK);
tft.fillRectVGradient(x + 2, y + 2, w - 5, h * 0.33, TFT_BLUE, 0x0011);
drawPidIcon(x + 4, y + 2, pidConfig[pid][2]);
tft.setTextColor(TFT_WHITE);
tft.drawCentreString(label, x + w / 2, y + 2, 4);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.drawRightString("---", x + w - 5, y + (h * 0.25) + 11, 6);
} // numericMeter
/*---------------------------*/
// plot number on digital Meter
void plotNumeric(uint8_t cell, float data, int warn, bool digit) {
int compare = warn / data; // compare data and warn
if (compare == 0) { // warning
int w = screenWidth / 2 - 1;
int h = screenHeight / 3 - 1;
int x = cell1_x[cell - 1];
int y = cell1_y[cell - 1];
if (blinkCell[cell]) { // show
String result = String(data, digit);
switch (result.length()) {
case 2:
result = " " + result;
break;
case 3:
result = " " + result;
break;
case 4:
result = " " + result;
break;
} // switch
old_data[pidIndex] = data; // update old data
tft.setTextColor(TFT_RED, TFT_BLACK); // red color font
tft.drawRightString(result.c_str(), x + w - 5, y + (h * 0.25) + 11, 6);
} else { // hiding make blinking effect
tft.fillRect(x + 2, y + 28, w - 5, 50, TFT_BLACK); // delete old number
}
blinkCell[cell] = !blinkCell[cell];
} else { // no warning
if (data != old_data[pidIndex]) { // if value change then redraw new data
int w = screenWidth / 2 - 1;
int h = screenHeight / 3 - 1;
int x = cell1_x[cell - 1];
int y = cell1_y[cell - 1];
String result = String(data, digit);
switch (result.length()) {
case 2:
result = " " + result;
break;
case 3:
result = " " + result;
break;
case 4:
result = " " + result;
break;
} // if data
tft.setTextColor(TFT_GREEN, TFT_BLACK); // green color font
tft.drawRightString(result.c_str(), x + w - 5, y + (h * 0.25) + 11, 6);
old_data[pidIndex] = data; // update old data
} // if nothing changed then skip
} // if warn/data == 0
}
/* ##########################
draw analog meter 160x80 px
##########################
cell
1 , 2 , 3
4 , 5 , 6
7 , 8 , 9 */
void arcMeter(uint8_t cell, uint8_t pid) {
int w = screenWidth / 2;
int h = screenHeight / 3;
int x = cell1_x[cell - 1];
int y = cell1_y[cell - 1];
// Array data -> { Label , unit, pid, fomula, min, max, ,skip, digit }
String label = pidConfig[pid][0]; // get PID to label
String unit = pidConfig[pid][1]; // get PID to unit
int min = pidConfig[pid][4].toInt(); // get PID to min
int max = pidConfig[pid][5].toInt(); // get PID to max
// Meter outline
tft.fillRect(x, y, w, h, TFT_GREY);
tft.fillRect(x + 2, y + 2, 156, 76, TFT_BLACK);
tft.setTextColor(TFT_WHITE); // Text colour
tft.drawCentreString(String(min).c_str(), x + 16, y + 10, 2);
tft.drawCentreString(String(max).c_str(), x + w - 16, y + 10, 2);
tft.drawRightString(unit.c_str(), x + 150, y + 60,
2); // Units at bottom right
tft.drawCentreString(label.c_str(), x + 80, y + 58,
2); // Comment out to avoid font 4
tft.setTextColor(TFT_GREEN);
tft.drawString("---", x + 10, y + 60, 2); // data
// draw arc animation
for (int angle = 135; angle < 225; angle++) {
tft.drawSmoothArc(x + 80, y + 90, 80, 60, 134, angle, TFT_GREEN,
TFT_DARKGREY, false);
}
for (int angle = 224; angle > 135; angle--) {
tft.drawSmoothArc(x + 80, y + 90, 80, 60, angle + 1, 226, TFT_WHITE,
TFT_DARKGREY, false);
}
}
// #########################################################################
void plotArc(uint8_t cell, String label, float data, int warn, int min, int max,
bool digit) {
if (data != old_data[pidIndex]) {
// int w = screenWidth / 2 - 1;
// int h = screenHeight / 3 - 1;
int x = cell1_x[cell - 1];
int y = cell1_y[cell - 1];
String result = String(data, digit);
switch (result.length()) {
case 2:
result = result + " ";
break;
case 3:
result = result + " ";
break;
case 4:
result = result + " ";
break;
}
if (data < min)
data = min;
if (data > max)
data = max;
int arcColor = TFT_GREEN;
int compare = warn / data; // compare data and warn
if (compare == 0) // warning
arcColor = TFT_RED; // red font red arc
else // no warn
arcColor = TFT_GREEN; // gren font green arc
tft.setTextColor(arcColor, TFT_BLACK);
tft.drawString(result.c_str(), x + 10, y + 60, 2); // data
int angle = map(data, min, max, 135, 225);
if (data >= old_data[pidIndex]) // new data > old data -> draw only green
// arc
tft.drawSmoothArc(x + 80, y + 90, 80, 60, 134, angle, arcColor,
TFT_DARKGREY, false);
else // new data < old data -> draw only white arc
tft.drawSmoothArc(x + 80, y + 90, 80, 60, angle + 1, 226, TFT_WHITE,
TFT_DARKGREY, false);
old_data[pidIndex] = data; // update old data
} // if
}
/* ######################################
draw linear vertical meter 80x240 px
######################################
cell 1 , 2 , 3 , 4 */
// vertical meter cell 1 to 4 full screen height
void vBarMeter(uint8_t cell, uint8_t pid) {
int x = cell2_x[cell - 1]; // start origin
int w = screenWidth / 4;
// Array data -> { Label , unit, pid, fomula, min, max, ,skip, digit }
String label = pidConfig[pid][0]; // get PID to label
String unit = pidConfig[pid][1]; // get PID to unit
// int min = pidConfig[pid][4].toInt(); //get PID to min
int max = pidConfig[pid][5].toInt(); // get PID to max
tft.drawRect(x, 0, w, screenHeight, TFT_DARKGREY);
tft.fillRectVGradient(x + 2, 2, w - 3, screenHeight * 0.12 - 10, TFT_BLUE,
0x0011);
tft.fillRect(x + 2, screenHeight * 0.12, w - 3,
screenHeight - screenHeight * 0.24, TFT_BLACK);
tft.setTextColor(TFT_WHITE);
tft.drawCentreString(label, x + w / 2, 2, 2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawString(unit, x + 3, screenHeight * 0.12 - 8, 1);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setTextPadding(w - 6);
tft.drawRightString("---", x + w - 4,
screenHeight - (screenHeight * 0.12) + 1, 4);
tft.setTextPadding(0);
float smalltick = screenHeight * 0.75 / 50;
// float bigtick = screenHeight*0.75/10;
// draw number
tft.setTextColor(TFT_WHITE, TFT_BLACK);
// tft.drawRigscreenHeighttString("120",x+w-2,y+screenHeight*0.12+2,1);
for (int i = 0; i < 51; i++) { // draw tick
tft.drawFastHLine(x + w / 3, screenHeight * 0.12 + smalltick * i + 3, 10,
TFT_WHITE);
if (i % 5 == 0) {
tft.drawFastHLine(x + w / 3, screenHeight * 0.12 + smalltick * i + 3, 20,
TFT_WHITE);
if (i % 10 == 0) {
int mark = max - (max * i * 2 / 100);
tft.drawRightString(String(mark), x + w - 3,
screenHeight * 0.12 + smalltick * i - 7, 2);
} // i%10
} // i%5
} // for i
// animation
for (int barH = 0; barH < 183; barH++) { // draw bar
tft.fillRect(x + 5, 212 - barH, 20, barH, TFT_RED); // lower
}
for (int barH = 182; barH >= 0; barH--) { // draw bar
tft.fillRect(x + 5, 29, 20, 183 - barH, TFT_BLACK); // upper
}
} // drawLinerVmeter
/*-------------------*/
// vertical meter cell 1 to 4 full screen height
void plotVBar(uint8_t cell, float data, int warn, int min, int max,
bool digit) { // cell 1-4
if (data != old_data[pidIndex]) { // if value change then redraw new data
int x = cell2_x[cell - 1];
int w = screenWidth / 4;
if (data < min)
data = min;
if (data > max)
data = max;
int barColor = TFT_GREEN;
int compare = warn / data; // compare data and warn
if (compare == 0) // warning
barColor = TFT_RED; // red bar red font
else // no warn
barColor = TFT_GREEN; // green bar green font
// draw new bar
int barH = map(data, min, max, 0, 182);
if (data > old_data[pidIndex]) // new data > old data -> draw red bar
tft.fillRectVGradient(x + 5, 212 - barH, 20, barH, barColor,
0x8000); // lower
else // new data < old data -> draw black bar
tft.fillRect(x + 5, 29, 20, 183 - barH, TFT_BLACK); // upper
tft.setTextColor(barColor, TFT_BLACK); // draw text
String result = String(data, digit);
switch (result.length()) {
case 2:
result = " " + result + " ";
break;
case 3:
result = " " + result + " ";
break;
case 4:
result = " " + result + " ";
break;
}
tft.setTextPadding(w - 6);
tft.drawRightString(result.c_str(), x + w - 4,
screenHeight - (screenHeight * 0.12) + 1, 4);
tft.setTextPadding(0);
old_data[pidIndex] = data; // update old data
} // if
}
/*-------------------*/
///*-------------------*/
// 7-segment bitmask table: a, b, c, d, e, f, g
static const uint8_t seg7_map[10] = {
0b00111111, // 0
0b00000110, // 1
0b01011011, // 2
0b01001111, // 3
0b01100110, // 4
0b01101101, // 5
0b01111101, // 6
0b00000111, // 7
0b01111111, // 8
0b01101111 // 9
};
void draw7SegString(int x, int y, const char *str, uint8_t dW, uint8_t dH,
uint8_t t, uint16_t fg, uint16_t bg) {
int curX = x;
while (*str) {
char c = *str++;
if (c >= '0' && c <= '9') {
uint8_t mask = seg7_map[c - '0'];
uint16_t ca = (mask & 0x01) ? fg : bg;
uint16_t cb = (mask & 0x02) ? fg : bg;
uint16_t cc = (mask & 0x04) ? fg : bg;
uint16_t cd = (mask & 0x08) ? fg : bg;
uint16_t ce = (mask & 0x10) ? fg : bg;
uint16_t cf = (mask & 0x20) ? fg : bg;
uint16_t cg = (mask & 0x40) ? fg : bg;
tft.fillRect(curX + t, y, dW - 2 * t, t, ca);
tft.fillRect(curX + dW - t, y + t, t, (dH / 2) - t, cb);
tft.fillRect(curX + dW - t, y + (dH / 2), t, (dH / 2) - t, cc);
tft.fillRect(curX + t, y + dH - t, dW - 2 * t, t, cd);
tft.fillRect(curX, y + (dH / 2), t, (dH / 2) - t, ce);
tft.fillRect(curX, y + t, t, (dH / 2) - t, cf);
tft.fillRect(curX + t, y + (dH / 2) - (t / 2), dW - 2 * t, t, cg);
curX += dW + t;
} else if (c == '-') {
tft.fillRect(curX + t, y + (dH / 2) - (t / 2), dW - 2 * t, t, fg);
curX += dW + t;
} else if (c == '.') {
tft.fillRect(curX + 1, y + dH - t, t, t, fg);
curX += t + 3;
} else if (c == ' ') {
curX += dW / 2;
}
}
}
int get7SegWidth(const char *str, uint8_t dW, uint8_t t) {
int total = 0;
while (*str) {
char c = *str++;
if (c >= '0' && c <= '9')
total += dW + t;
else if (c == '-')
total += dW + t;
else if (c == '.')
total += t + 3;
else if (c == ' ')
total += dW / 2;
}
if (total > t)
total -= t;
return total;
}
// Draw blue header background for a 160x120 quadrant (25px height) with lateral
// and vertical smooth fade
void drawQuadHeaderGradient(uint8_t cell) {
int16_t ox = cell4_x[cell];
int16_t oy = cell4_y[cell];
uint16_t lineBuf[160];
const float fade_w_left = 12.0f;
const float fade_w_right = 12.0f;
const float x_right_start = 160.0f - fade_w_right;
tft.startWrite();
tft.setAddrWindow(ox, oy, 160, 25);
for (int16_t y = 0; y < 25; y++) {
float vy;
if (y < 14) {
vy = 1.0f;
} else {
float t = (24.0f - (float)y) / 10.0f;
vy = t * t * (3.0f - 2.0f * t);
}
for (int16_t x = 0; x < 160; x++) {
float hx = 1.0f;
if (x < (int16_t)fade_w_left) {
float t = (float)x / fade_w_left;
hx = t * t * (3.0f - 2.0f * t);
} else if (x > (int16_t)x_right_start) {
float t = (159.0f - (float)x) / fade_w_right;
if (t < 0.0f)
t = 0.0f;
hx = t * t * (3.0f - 2.0f * t);
}
float b = vy * hx;
uint8_t g = (uint8_t)(140.0f * b);
uint8_t bl = (uint8_t)(255.0f * b);
lineBuf[x] = tft.color565(0, g, bl);
}
tft.pushPixels(lineBuf, 160);
}
tft.endWrite();
}
// Forward declarations
void plotArc4(uint8_t cell, float data, int warn, int min, int max, bool digit,
String unit);
void plotFullScreen(uint8_t cell, float data, int warn, int min, int max,
bool digit, String unit);
void plotNoData(uint8_t cell);
// 4-quadrant divider styling
const uint16_t QUAD_DIV_COLOR = 0x632C;
inline void drawQuadrantDividers() {
uint16_t divCol = getThemeDivColor();
tft.fillRect(159, 0, 2, 240, divCol);
tft.fillRect(0, 119, 320, 2, divCol);
}
// Determine display color for meter:
// Instrumentos: Color del tema activo (normal) o TFT_RED (alarma/peligro)
inline uint16_t getMeterColor(uint8_t pidIdx, float data, int warn = -1) {
if (pidIdx >= 4)
return getThemePrimaryColor();
float warnVal = (warn > 0) ? (float)warn : warningValue[pidIdx].toFloat();
if (pidIdx == 0) { // 0 = Temperatura ECT (PID 0105)
if (warnVal <= 0 || warnVal == 106.0f)
warnVal = 110.0f;
if (data >= warnVal)
return TFT_RED;
return getThemePrimaryColor();
}
if (pidIdx == 1) { // 1 = RPM (PID 010C)
if (warnVal <= 0)
warnVal = 5000.0f;
if (data >= warnVal)
return TFT_RED;
return getThemePrimaryColor();
}
if (pidIdx == 2) { // 2 = Voltaje (PID 0142)
if (warnVal <= 0)
warnVal = 15.0f;
// Si el dato es 0 (contacto apagado / desconectado) o motor detenido, no
// disparar alarma por < 12V
if (data <= 1.0f)
return getThemePrimaryColor();
if (currentRpm <= 0.0f) {
if (data >= warnVal)
return TFT_RED;
return getThemePrimaryColor();
}
if (data < 12.0f || data >= warnVal)
return TFT_RED;
return getThemePrimaryColor();
}
if (pidIdx == 3) { // 3 = Velocidad (PID 010D)
if (warnVal <= 0)
warnVal = 130.0f;
if (data >= warnVal)
return TFT_RED;
return getThemePrimaryColor();
}
if (warnVal > 0 && data >= warnVal)
return TFT_RED;
return getThemePrimaryColor();
}
// Compute block count (0..NUM_BAR_BLOCKS) with sub-integer floating point
// precision
inline int calcMeterBlocks(float val, int min, int max) {
float range = (float)(max - min);
if (range <= 0.0f)
return 0;
if (val <= (float)min)
return 0;
if (val >= (float)max)
return NUM_BAR_BLOCKS;
int b = (int)(((val - (float)min) / range) * (float)NUM_BAR_BLOCKS + 0.5f);
if (b < 0)
b = 0;
if (b > NUM_BAR_BLOCKS)
b = NUM_BAR_BLOCKS;
return b;
}
// Restore natural vignette vertical gradient where GPS icon sits
inline void clearPage0GpsIcon() {
uint16_t c1 = tft.alphaBlend((uint8_t)(255 * (82 - 3) / 82),
getThemeBgColor(), TFT_BLACK);
uint16_t c2 = tft.alphaBlend((uint8_t)(255 * (82 - 25) / 82),
getThemeBgColor(), TFT_BLACK);
tft.fillRectVGradient(294, 3, 24, 22, c1, c2);
}
inline void clearFullscreenGpsIcon() {
uint16_t c1 = tft.alphaBlend((uint8_t)(255 * (145 - 3) / 145),
getThemeBgColor(), TFT_BLACK);
uint16_t c2 = tft.alphaBlend((uint8_t)(255 * (145 - 25) / 145),
getThemeBgColor(), TFT_BLACK);
tft.fillRectVGradient(294, 3, 24, 22, c1, c2);
}
// Draw animated GPS satellite icon in top right of Quadrant 1 (Velocidad: cell
// 1)
inline void drawPage0GpsIcon(uint8_t waveState, bool synced) {
if (currentScreenPage != 0 || fullScreenCell != -1)
return;
clearPage0GpsIcon();
uint16_t waveCol = getThemePrimaryColor();
uint16_t bodyCol = TFT_WHITE;
uint16_t bgCol = tft.alphaBlend((uint8_t)(255 * (82 - 3) / 82),
getThemeBgColor(), TFT_BLACK);
drawGpsSatelliteIcon(tft, 296, 3, bodyCol, waveCol, bgCol, waveState);
}
// Draw animated GPS satellite icon in top right of Fullscreen Velocidad (cell
// 1)
inline void drawFullscreenGpsIcon(uint8_t waveState, bool synced) {
if (fullScreenCell != 1)
return;
clearFullscreenGpsIcon();
uint16_t waveCol = getThemePrimaryColor();
uint16_t bodyCol = TFT_WHITE;
uint16_t bgCol = tft.alphaBlend((uint8_t)(255 * (145 - 5) / 145),
getThemeBgColor(), TFT_BLACK);
drawGpsSatelliteIcon(tft, 296, 5, bodyCol, waveCol, bgCol, waveState);
}
static uint8_t lastGpsIconWaveState = 255;
static bool lastGpsIconSyncState = false;
static bool lastGpsIconSpeed = false;
static int8_t lastGpsIconPage = -1;
static int8_t lastGpsIconFsCell = -2;
inline void syncGpsIconAnimTrackers(int8_t page, int8_t fsCell) {
lastGpsIconWaveState = getGpsWaveAnimState();
lastGpsIconSyncState = isGpsSynced();
lastGpsIconSpeed = isGpsSpeedValid();
lastGpsIconPage = page;
lastGpsIconFsCell = fsCell;
}
// Live update of satellite icon animation in active screen
inline void updateGpsIconAnim() {
bool curGpsSpeed = isGpsSpeedValid();
int8_t curPage = currentScreenPage;
int8_t curFsCell = fullScreenCell;
// Si el GPS no esta activo entregando velocidad, no hay icono en pantalla
if (!curGpsSpeed) {
if (lastGpsIconSpeed) {
// Si antes estaba visible y se desconecto/perdio fix, restaurar el
// gradiente de fondo
if (curPage == 0 && curFsCell == -1) {
clearPage0GpsIcon();
} else if (curFsCell == 1) {
clearFullscreenGpsIcon();
}
lastGpsIconSpeed = false;
}
return;
}
uint8_t curWaveState = getGpsWaveAnimState();
bool curSync = isGpsSynced();
if (curWaveState == lastGpsIconWaveState && curSync == lastGpsIconSyncState &&
curGpsSpeed == lastGpsIconSpeed && curPage == lastGpsIconPage &&
curFsCell == lastGpsIconFsCell) {
return;
}
lastGpsIconWaveState = curWaveState;
lastGpsIconSyncState = curSync;
lastGpsIconSpeed = curGpsSpeed;
lastGpsIconPage = curPage;
lastGpsIconFsCell = curFsCell;
if (curPage == 0 && curFsCell == -1) {
drawPage0GpsIcon(curWaveState, curSync);
} else if (curFsCell == 1) {
drawFullscreenGpsIcon(curWaveState, curSync);
}
}
// Synchronize Page 0 tracking variables without touching TFT display (zero
// flicker)
inline void syncPage0Trackers() {
badgeEcoDrawn = isEcoOptimal;
for (uint8_t c = 0; c < 4; c++) {
old_data[c] = last_valid_data[c];
wasInRedAlarm[c] = false;
}
syncGpsIconAnimTrackers(0, -1);
}
// 4-quadrant Exponential Segmented Block Meter (160x120 px) - identical to full
// screen style
void arcMeter4(uint8_t cell, uint8_t pid, bool redrawHeader = true) {
int x = cell4_x[cell];
int y = cell4_y[cell];
int rx = (cell & 1) ? 161 : 0;
int ry = (cell & 2) ? 121 : 0;
// Ambient vignette gradient (theme tint fading to black)
tft.fillRectVGradient(rx, ry, 159, 82, getThemeBgColor(), TFT_BLACK);
tft.fillRect(rx, ry + 82, 159, 37, TFT_BLACK);
drawPidIcon(x + 5, y + 3, pidConfig[pid][2]);
drawQuadrantDividers();
// Initialize all 20 blocks in METER_INACTIVE_COLOR
for (uint8_t i = 0; i < NUM_BAR_BLOCKS; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
int min = pidConfig[pid][4].toInt();
int max = pidConfig[pid][5].toInt();
bool digit = pidConfig[pid][7].toInt();
int warn = warningValue[pid].toInt();
String unit = pidConfig[pid][1];
if (last_valid_data[cell] != -999) {
old_data[cell] = -999;
plotArc4(cell, last_valid_data[cell], warn, min, max, digit, unit);
} else {
// Value placeholder using DSEG7 Modern 34pt smooth font with rainbow gradient
bool rainbowTxt = isRainbowTheme();
drawDseg7RightString34(tft, "---", x + 152, y + 74, getThemeCellColor(cell), TFT_BLACK, 114, 240, rainbowTxt);
old_data[cell] = -999;
}
if (cell == 1 && isGpsSpeedValid()) {
drawPage0GpsIcon(getGpsWaveAnimState(), isGpsSynced());
}
}
void plotArc4(uint8_t cell, float data, int warn, int min, int max, bool digit,
String unit) {
if (data == old_data[cell])
return;
int x = cell4_x[cell];
int y = cell4_y[cell];
uint8_t pidIdx = pidInCell[cell];
uint16_t col = getMeterColor(pidIdx, data, warn);
uint16_t oldCol = (old_data[cell] <= -900)
? 0
: getMeterColor(pidIdx, old_data[cell], warn);
String numStr = String(data, digit);
int newBlocks = calcMeterBlocks(data, min, max);
int oldBlocks =
(old_data[cell] <= -900) ? 0 : calcMeterBlocks(old_data[cell], min, max);
bool colorChanged = (col != oldCol);
if (col == TFT_RED) {
if (!wasInRedAlarm[cell]) {
drawPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
}
wasInRedAlarm[cell] = true;
if (colorChanged) {
arcFlashVisible = true;
lastArcFlashTime = millis();
drawPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
for (uint8_t i = 0; i < newBlocks; i++) {
drawQuadBarBlock(cell, i, TFT_RED);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
} else if (arcFlashVisible || !alarmEnabled[pidIdx]) {
if (newBlocks > oldBlocks) {
for (uint8_t i = oldBlocks; i < newBlocks; i++) {
drawQuadBarBlock(cell, i, TFT_RED);
}
} else if (newBlocks < oldBlocks) {
for (uint8_t i = newBlocks; i < oldBlocks; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
}
}
} else {
if (wasInRedAlarm[cell]) {
drawPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
}
wasInRedAlarm[cell] = false;
if (colorChanged || old_data[cell] <= -900) {
for (uint8_t i = 0; i < newBlocks; i++) {
drawQuadBarBlock(cell, i, col);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
} else if (newBlocks > oldBlocks) {
for (uint8_t i = oldBlocks; i < newBlocks; i++) {
drawQuadBarBlock(cell, i, col);
}
} else if (newBlocks < oldBlocks) {
for (uint8_t i = newBlocks; i < oldBlocks; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
}
}
uint16_t txtCol = (col == TFT_RED) ? TFT_RED : getThemeCellColor(cell);
bool rainbowTxt = isRainbowTheme() && (col != TFT_RED);
drawDseg7RightString34(tft, numStr.c_str(), x + 152, y + 74, txtCol, TFT_BLACK, 114, 240, rainbowTxt);
old_data[cell] = data;
// Draw GPS Satellite Icon & indicator in top-right corner of cell 1
// (Velocidad)
if (cell == 1 && isGpsSpeedValid()) {
drawPage0GpsIcon(getGpsWaveAnimState(), isGpsSynced());
}
}
void fullScreenMeter(uint8_t cell, uint8_t pid, bool redrawHeader = true) {
fullScreenCell = cell;
String label = pidConfig[pid][0];
String unit = pidConfig[pid][1];
if (unit == "volt")
unit = "V";
if (redrawHeader) {
// Ambient vignette gradient from y=0 fading to black (matching 4 quadrants
// style)
tft.fillRectVGradient(0, 0, 320, 145, getThemeBgColor(), TFT_BLACK);
tft.fillRect(0, 145, 320, 95, TFT_BLACK);
drawPidIconHeader(10, 4, pidConfig[pid][2]);
uint16_t titleCol = (getMeterColor(pid, last_valid_data[cell]) == TFT_RED &&
alarmEnabled[pid])
? TFT_RED
: TFT_WHITE;
if (titleCol == TFT_WHITE && isYapaTheme()) {
drawYapaCentreString(label, 175, 12, 4);
} else if (titleCol == TFT_WHITE && isRainbowTheme()) {
drawRainbowCentreString(label.c_str(), 175, 12, 4);
} else {
tft.setTextColor(titleCol);
tft.drawCentreString(label, 175, 12, 4);
}
if (cell == 1 && isGpsSpeedValid()) {
drawFullscreenGpsIcon(getGpsWaveAnimState(), isGpsSynced());
}
} else {
tft.fillRectVGradient(0, 51, 320, 94,
tft.alphaBlend((uint8_t)(255 * (145 - 51) / 145),
getThemeBgColor(), TFT_BLACK),
TFT_BLACK);
tft.fillRect(0, 145, 320, 95, TFT_BLACK);
}
int min = pidConfig[pid][4].toInt();
int max = pidConfig[pid][5].toInt();
bool digit = pidConfig[pid][7].toInt();
int warn = warningValue[pid].toInt();
if (last_valid_data[cell] != -999) {
old_data[cell] = -999;
plotFullScreen(cell, last_valid_data[cell], warn, min, max, digit, unit);
} else {
// Exponential segmented block mode full screen (18 discrete blocks, 3mm x
// 5mm, 1mm gap)
for (uint8_t i = 0; i < NUM_BAR_BLOCKS; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
bool rainbowTxt = isRainbowTheme();
drawDseg7RightString80(tft, "---", 312, 148, getThemeCellColor(cell), TFT_BLACK, 240, 240, rainbowTxt);
old_data[cell] = -999;
}
}
void plotFullScreen(uint8_t cell, float data, int warn, int min, int max,
bool digit, String unit) {
if (data == old_data[cell])
return;
uint8_t pidIdx = pidInCell[cell];
uint16_t col = getMeterColor(pidIdx, data, warn);
uint16_t oldCol = (old_data[cell] <= -900)
? 0
: getMeterColor(pidIdx, old_data[cell], warn);
String numStr = String(data, digit);
int newBlocks = calcMeterBlocks(data, min, max);
int oldBlocks =
(old_data[cell] <= -900) ? 0 : calcMeterBlocks(old_data[cell], min, max);
bool colorChanged = (col != oldCol);
if (col == TFT_RED) {
if (!wasInRedAlarm[cell]) {
drawPidIconHeader(10, 4, pidConfig[pidIdx][2]);
}
wasInRedAlarm[cell] = true;
if (colorChanged) {
arcFlashVisible = true;
lastArcFlashTime = millis();
drawPidIconHeader(10, 4, pidConfig[pidIdx][2]);
if (alarmEnabled[pidIdx]) {
drawFullScreenTitle(pidConfig[pidIdx][0], TFT_RED);
}
for (uint8_t i = 0; i < newBlocks; i++) {
drawBarBlock(i, TFT_RED);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
} else if (arcFlashVisible || !alarmEnabled[pidIdx]) {
if (newBlocks > oldBlocks) {
for (uint8_t i = oldBlocks; i < newBlocks; i++) {
drawBarBlock(i, TFT_RED);
}
} else if (newBlocks < oldBlocks) {
for (uint8_t i = newBlocks; i < oldBlocks; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
}
}
} else {
if (wasInRedAlarm[cell]) {
drawPidIconHeader(10, 4, pidConfig[pidIdx][2]);
}
wasInRedAlarm[cell] = false;
if (colorChanged || old_data[cell] <= -900) {
for (uint8_t i = 0; i < newBlocks; i++) {
drawBarBlock(i, col);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
} else if (newBlocks > oldBlocks) {
for (uint8_t i = oldBlocks; i < newBlocks; i++) {
drawBarBlock(i, col);
}
} else if (newBlocks < oldBlocks) {
for (uint8_t i = newBlocks; i < oldBlocks; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
}
}
uint16_t txtCol = (col == TFT_RED) ? TFT_RED : getThemeCellColor(cell);
bool rainbowTxt = isRainbowTheme() && (col != TFT_RED);
// Value bottom-right using DSEG7 Modern smooth font with rainbow gradient
drawDseg7RightString80(tft, numStr.c_str(), 312, 148, txtCol, TFT_BLACK, 240, 240, rainbowTxt);
old_data[cell] = data;
if (cell == 1 && isGpsSpeedValid()) {
drawFullscreenGpsIcon(getGpsWaveAnimState(), isGpsSynced());
}
}
// Display "---" (no data) with all blocks inactive on specified quadrant or
// full screen
void plotNoData(uint8_t cell) {
if (cell >= 4)
return;
if (cell == 1 && isGpsSpeedValid()) {
return; // Si el GPS esta activo y entregando velocidad, nunca pisar con
// "---"
}
if (old_data[cell] <= -900.0f && last_valid_data[cell] <= -900.0f) {
return; // Already showing no data, skip redraw to avoid SPI overhead
}
old_data[cell] = -999.0f;
last_valid_data[cell] = -999.0f;
wasInRedAlarm[cell] = false;
uint8_t pidIdx = pidInCell[cell];
if (fullScreenCell >= 0 && fullScreenCell < 4) {
if (cell == fullScreenCell) {
clearPidIconHeader();
drawPidIconHeader(10, 4, pidConfig[pidIdx][2]);
for (uint8_t i = 0; i < NUM_BAR_BLOCKS; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
tft.loadFont(DSEG7_Modern_BoldItalic_80);
tft.setTextColor(getThemeCellColor(cell), TFT_BLACK);
tft.setTextPadding(240);
tft.drawRightString("---", 312, 148, 1);
tft.setTextPadding(0);
tft.unloadFont();
}
} else if (currentScreenPage == 0) {
int x = cell4_x[cell];
int y = cell4_y[cell];
clearPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
drawPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
for (uint8_t i = 0; i < NUM_BAR_BLOCKS; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
tft.loadFont(DSEG7_Modern_BoldItalic_34);
tft.setTextColor(getThemeCellColor(cell), TFT_BLACK);
tft.setTextPadding(114);
tft.drawRightString("---", x + 152, y + 74, 1);
tft.setTextPadding(0);
tft.unloadFont();
}
}
// Draw parameter icon into sprite slice with true 8-bit alpha channel blended
// against theme background
inline void drawPidIconToSprite(TFT_eSprite &s, int ix, int iy,
const String &pidStr, int y_slice, int slice_h,
int global_ry) {
int w = 42, h = 42;
const unsigned short *colors = nullptr;
const unsigned char *alphas = nullptr;
if (pidStr == "0105") {
w = 38;
h = 37;
colors = icon_temp_quad;
alphas = icon_temp_quad_alpha;
} else if (pidStr == "010D") {
w = 42;
h = 42;
colors = icon_speed_quad;
alphas = icon_speed_quad_alpha;
} else if (pidStr == "010C") {
w = 42;
h = 43;
colors = icon_rpm_quad;
alphas = icon_rpm_quad_alpha;
} else if (pidStr == "0142" || pidStr == "ATRV") {
w = 46;
h = 35;
colors = icon_battery_quad;
alphas = icon_battery_quad_alpha;
}
if (!colors || !alphas)
return;
int startJ = (iy < 0) ? -iy : 0;
int endJ = (iy + h > slice_h) ? (slice_h - iy) : h;
if (startJ >= endJ)
return;
for (int j = startJ; j < endJ; j++) {
int curY = iy + j;
int globalY = y_slice + curY;
uint16_t bgCol = getInstrumentBgColor(globalY, true, global_ry);
int rowOffset = j * w;
for (int i = 0; i < w; i++) {
int curX = ix + i;
if (curX < 0 || curX >= 320)
continue;
uint8_t a = pgm_read_byte(&alphas[rowOffset + i]);
if (a == 0)
continue;
uint16_t fg = pgm_read_word(&colors[rowOffset + i]);
if (a >= 250) {
s.drawPixel(curX, curY, fg);
} else {
s.drawPixel(curX, curY, fastBlend(a, fg, bgCol));
}
}
}
}
// Draw an individual solid curved block into sprite slice with subpixel
// anti-aliasing
inline void drawQuadBarBlockToSprite(TFT_eSprite &s, int16_t qx, int16_t qy,
uint8_t i, uint16_t color, int slice_h) {
if (isRainbowTheme() && color != TFT_RED && color != METER_INACTIVE_COLOR && color != TFT_BLACK)
color = getRainbowBlockColor(i);
int16_t minX = pgm_read_word(&quadBlockMinX[i]);
int16_t maxX = pgm_read_word(&quadBlockMaxX[i]);
int16_t minY = pgm_read_word(&quadBlockMinY[i]);
int16_t maxY = pgm_read_word(&quadBlockMaxY[i]);
if (qx + maxX < 0 || qx + minX >= 320)
return;
if (qy + maxY < 0 || qy + minY >= slice_h)
return;
int16_t startY = minY;
int16_t endY = maxY;
if (qy + startY < 0)
startY = -qy;
if (qy + endY >= slice_h)
endY = slice_h - 1 - qy;
int16_t startX = minX;
int16_t endX = maxX;
if (qx + startX < 0)
startX = -qx;
if (qx + endX >= 320)
endX = 319 - qx;
float nx[4], ny[4], c[4];
for (uint8_t k = 0; k < 4; k++) {
nx[k] = pgm_read_float(&quadBlockNx[i][k]);
ny[k] = pgm_read_float(&quadBlockNy[i][k]);
c[k] = pgm_read_float(&quadBlockC[i][k]);
}
for (int16_t y = startY; y <= endY; y++) {
float py = (float)y + 0.5f;
int16_t sy = qy + y;
for (int16_t x = startX; x <= endX; x++) {
float px = (float)x + 0.5f;
float min_d = 999.0f;
for (uint8_t k = 0; k < 4; k++) {
float d = px * nx[k] + py * ny[k] + c[k];
if (d < min_d)
min_d = d;
}
if (min_d <= -0.5f)
continue;
int16_t sx = qx + x;
if (color == TFT_BLACK) {
s.drawPixel(sx, sy, TFT_BLACK);
} else {
if (min_d >= 0.5f) {
s.drawPixel(sx, sy, color);
} else {
uint8_t alpha = (uint8_t)((min_d + 0.5f) * 255.0f);
s.drawPixel(sx, sy, fastBlend(alpha, color, (uint16_t)TFT_BLACK));
}
}
}
}
}
// Render Page 0 (4 Quadrants) into double-buffered Sprite slice
inline void renderPage0ToSprite(TFT_eSprite &s, int x_offset, int y_slice,
int slice_h) {
checkUpdateThemeGradients();
uint16_t divCol = getThemeDivColor();
uint16_t priCol = getThemePrimaryColor();
for (uint8_t c = 0; c < 4; c++) {
int qx = cell4_x[c] + x_offset;
int qy = cell4_y[c] - y_slice;
int rx = (c & 1) ? (161 + x_offset) : (0 + x_offset);
int global_ry = (c & 2) ? 121 : 0;
int ry = global_ry - y_slice;
if (qx + 160 <= 0 || qx >= 320 || ry + 119 <= 0 || ry >= slice_h)
continue;
// 1. Exact dark vignette gradient matching real arcMeter4
s.fillRect(rx, ry, 159, 119, TFT_BLACK);
int gradStartY = (ry < 0) ? -ry : 0;
int gradEndY = (ry + 82 > slice_h) ? (slice_h - ry) : 82;
for (int j = gradStartY; j < gradEndY; j++) {
int curY = ry + j;
if (curY >= 0 && curY < slice_h) {
s.drawFastHLine(rx, curY, 159, themeGradQuad[j]);
}
}
uint8_t pidIdx = pidInCell[c];
// 2. Real high-definition parameter icon (thermometer, speedometer,
// tachometer, battery)
drawPidIconToSprite(s, qx + 5, qy + 3, pidConfig[pidIdx][2], y_slice,
slice_h, global_ry);
// 3. Exact 20 curved segmented gauge blocks along the arc (true
// mathematical polygons)
int warn = warningValue[pidIdx].toInt();
int min = pidConfig[pidIdx][4].toInt();
int max = pidConfig[pidIdx][5].toInt();
bool digit = pidConfig[pidIdx][7].toInt();
float val = last_valid_data[c];
int numActive = (val > -900.0f) ? calcMeterBlocks(val, min, max) : 0;
uint16_t activeCol =
(val > -900.0f) ? getMeterColor(pidIdx, val, warn) : priCol;
for (uint8_t i = 0; i < NUM_BAR_BLOCKS; i++) {
uint16_t bCol = (i < numActive) ? activeCol : METER_INACTIVE_COLOR;
drawQuadBarBlockToSprite(s, qx, qy, i, bCol, slice_h);
}
// 4. Digital value string using authentic DSEG7 34pt font with rainbow gradient
String numStr = (val <= -900.0f) ? "---" : String(val, digit);
uint16_t txtCol = (activeCol == TFT_RED) ? TFT_RED : getThemeCellColor(c);
bool rainbowTxt = isRainbowTheme() && (activeCol != TFT_RED);
int textY = qy + 74;
if (textY < slice_h && textY + 40 > 0) {
drawDseg7RightString34(s, numStr.c_str(), qx + 152, textY, txtCol, TFT_BLACK, 0, slice_h, rainbowTxt);
}
// GPS Satellite icon in top right of Quadrant 1 (Velocidad: cell 1)
if (c == 1 && isGpsSpeedValid() && y_slice == 0) {
uint16_t waveCol = isRainbowTheme() ? (uint16_t)0x07E0 : priCol;
uint16_t bodyCol = TFT_WHITE;
uint16_t bgCol = themeGradQuad[3];
drawGpsSatelliteIcon(s, qx + 136, 3, bodyCol, waveCol, bgCol,
getGpsWaveAnimState());
}
}
// 5. Eco Badge if active
if (isEcoOptimal) {
int rpmCell = 2;
for (int c = 0; c < 4; c++) {
if (pidConfig[pidInCell[c]][3].toInt() == 3) {
rpmCell = c;
break;
}
}
int bx = cell4_x[rpmCell] + 100 + x_offset;
int by = cell4_y[rpmCell] + 4 - y_slice;
int bw = 48, bh = 18;
if (by + bh > 0 && by < slice_h && bx + bw > 0 && bx < 320) {
s.fillRoundRect(bx, by, bw, bh, 4, 0x0202);
s.drawRoundRect(bx, by, bw, bh, 4, 0x07E0);
s.setTextColor(TFT_WHITE, 0x0202);
s.drawCentreString("ECO", bx + (bw / 2), by + 1, 2);
}
}
// 6. Crosshair dividers
s.fillRect(159 + x_offset, 0, 2, slice_h, divCol);
int hLineY = 119 - y_slice;
if (hLineY >= 0 && hLineY < slice_h) {
s.fillRect(x_offset, hLineY, 320, 2, divCol);
}
}
// Render Page 1 (3 Temperature Cards) into double-buffered Sprite slice
inline void renderPage1ToSprite(TFT_eSprite &s, int x_offset, int y_slice,
int slice_h) {
for (uint8_t cardIdx = 0; cardIdx < 3; cardIdx++) {
int cardY = (19 + cardIdx * 74) - y_slice;
int cardH = 68;
int cardX = 4 + x_offset;
int cardW = 312;
if (cardX + cardW <= 0 || cardX >= 320 || cardY + cardH <= 0 ||
cardY >= slice_h)
continue;
uint16_t cardBg = 0x10A2;
uint16_t cardBorder = 0x2945;
s.fillRoundRect(cardX, cardY, cardW, cardH, 6, cardBg);
s.drawRoundRect(cardX, cardY, cardW, cardH, 6, cardBorder);
uint16_t accentCol = getThemePrimaryColor();
const char *titleStr = "";
const char *subStr = "";
float val = 0.0f;
float minRange = 0.0f, maxRange = 100.0f;
bool isInt = false;
if (cardIdx == 0) {
titleStr = "AGUA";
subStr = ignitionOn ? "ECU OBD-II (PID 0105)" : "ECU OBD-II (STANDBY)";
val = tempEngineCoolant;
minRange = 60.0f;
maxRange = 120.0f;
isInt = true;
if (val >= 110.0f)
accentCol = TFT_RED;
else if (val >= 100.0f)
accentCol = TFT_YELLOW;
else
accentCol = isRainbowTheme() ? (uint16_t)0x07FF : getThemePrimaryColor();
} else if (cardIdx == 1) {
titleStr = "INTERIOR";
subStr = (dsSensorCount > 0) ? "DS18B20 SENSOR 1 (GPIO 22)"
: "DS18B20 SENSOR 1";
val = tempInterior;
minRange = 10.0f;
maxRange = 40.0f;
accentCol = isRainbowTheme() ? (uint16_t)0x07E0 : getThemeAccentColor();
} else {
titleStr = "EXTERIOR";
subStr = (dsSensorCount > 1) ? "DS18B20 SENSOR 2 (GPIO 22)"
: (dsSensorCount == 1 ? "ESTIMADA (-4.5`C)"
: "DS18B20 SENSOR 2");
val = tempExterior;
minRange = -10.0f;
maxRange = 50.0f;
accentCol = isRainbowTheme() ? (uint16_t)0xFD20 : getThemePrimaryColor();
}
s.fillRoundRect(cardX + 6, cardY + 6, 4, cardH - 12, 2, accentCol);
// Smooth anti-aliased title in transition sprite (zero mallocs, 100%
// smooth)
if (!sprTriTitleReady)
initTriTempTitles();
if (sprTriTitleReady && sprTriTitle[cardIdx].created()) {
sprTriTitle[cardIdx].pushToSprite(&s, cardX + 16, cardY + 5);
} else {
s.setTextColor(TFT_WHITE, cardBg);
s.drawString(titleStr, cardX + 16, cardY + 5, 4);
}
if (isYapaTheme()) {
drawYapaStringToSprite(s, subStr, cardX + 16, cardY + 32, 1);
} else {
s.setTextColor(0x8410, cardBg);
s.drawString(subStr, cardX + 16, cardY + 32, 1);
}
// Progress bar
int barX = cardX + 16;
int barY = cardY + 48;
int barW = 120;
s.fillRoundRect(barX, barY, barW, 8, 3, 0x18C3);
float ratio = (val - minRange) / (maxRange - minRange);
if (ratio < 0.0f)
ratio = 0.0f;
if (ratio > 1.0f)
ratio = 1.0f;
int fillW = (int)(barW * ratio);
if (fillW < 4)
fillW = 4;
s.fillRoundRect(barX, barY, fillW, 8, 3, accentCol);
// Value text using authentic DSEG7 34pt font with rainbow gradient
String numStr =
(val <= -900.0f) ? "---" : (isInt ? String((int)val) : String(val, 1));
bool rainbowTxt = isRainbowTheme() && (accentCol != TFT_RED);
int numY = cardY + 20;
if (numY < slice_h && numY + 40 > 0) {
drawDseg7RightString34(s, numStr.c_str(), cardX + 262, numY, accentCol, cardBg, 0, slice_h, rainbowTxt);
}
int unitY = cardY + 22;
if (unitY < slice_h && unitY + 26 > 0) {
bool rainbowUnit = isRainbowTheme() && (accentCol != TFT_RED);
uint16_t unitCol = rainbowUnit ? getRainbowScreenColor(cardX + 268, unitY) : (isYapaTheme() && accentCol != TFT_RED ? getYapaColor(numStr.length()) : (accentCol == TFT_RED ? TFT_RED : 0xC618));
draw7SegDegreeC(s, cardX + 268, unitY, unitCol, cardBg, rainbowUnit);
}
}
}
// 100% Flicker-Free Backlight Dip / Fade-to-Black Transition
// Fades hardware backlight smoothly to black, renders the new page invisibly
// in the dark with zero tear, and smoothly restores backlight.
inline void animateBlackTransition(int8_t toPage = -1) {
if (toPage < 0)
toPage = (currentScreenPage + 1) % 4;
if (!sprTransAllocated) {
initScreen(true);
return;
}
uint8_t startBrightness = (uint8_t)currentBacklight;
if (startBrightness < 20)
startBrightness = 20;
// 1. Smoothly fade backlight down to black in ~200ms (quadratic ease for natural eye response)
const uint8_t fadeSteps = 16;
for (int8_t i = fadeSteps - 1; i >= 0; i--) {
float t = (float)i / (float)fadeSteps;
uint8_t b = (uint8_t)(startBrightness * (t * t));
ledcWrite(backlightChannel, b);
delay(12);
}
ledcWrite(backlightChannel, 0);
// 2. Render new screen slice-by-slice while backlight is totally off (100% invisible redraw)
const int slice_h = 40;
const uint8_t numSlices = 240 / slice_h; // exactly 6 slices
for (uint8_t s = 0; s < numSlices; s++) {
int y_slice = s * slice_h;
sprTrans.fillSprite(TFT_BLACK);
if (toPage == 0)
renderPage0ToSprite(sprTrans, 0, y_slice, slice_h);
else if (toPage == 1)
renderPage1ToSprite(sprTrans, 0, y_slice, slice_h);
else if (toPage == 2)
renderPage2ToSprite(sprTrans, 0, y_slice, slice_h);
else
renderPage3ToSprite(sprTrans, 0, y_slice, slice_h);
// Pagination indicator dots on top slice (4 dots for 4 pages)
if (y_slice == 0) {
sprTrans.fillCircle(139, 2, 2,
(toPage == 0) ? getThemePrimaryColor() : 0x5AEB);
sprTrans.fillCircle(153, 2, 2,
(toPage == 1) ? getThemePrimaryColor() : 0x5AEB);
sprTrans.fillCircle(167, 2, 2,
(toPage == 2) ? getThemePrimaryColor() : 0x5AEB);
sprTrans.fillCircle(181, 2, 2,
(toPage == 3) ? getThemePrimaryColor() : 0x5AEB);
}
sprTrans.pushSprite(0, y_slice);
}
// 3. Smoothly fade backlight back up to original level in ~200ms
for (uint8_t i = 1; i <= fadeSteps; i++) {
float t = (float)i / (float)fadeSteps;
uint8_t b = (uint8_t)(startBrightness * (t * t));
ledcWrite(backlightChannel, b);
delay(12);
}
currentBacklight = (float)startBrightness;
ledcWrite(backlightChannel, (uint32_t)targetBacklight);
sprTrans.fontLoaded = false;
}
// 100% Flicker-Free Double-Buffered Crossfade Transition
// Ultra-fast parallel 32-bit RGB565 blend with zero static RAM allocation
inline void animateFadeTransition(int8_t fromPage = -1, int8_t toPage = -1) {
if (fromPage < 0)
fromPage = currentScreenPage;
if (toPage < 0)
toPage = (fromPage == 0) ? 1 : 0;
#ifdef SERIAL_DEBUG
Serial.printf(">>> [DB] animateFadeTransition ENTER: fromPage=%d, toPage=%d, "
"heap=%u, maxAlloc=%u\n",
fromPage, toPage, ESP.getFreeHeap(), ESP.getMaxAllocHeap());
#endif
const int slice_h = 40;
const uint8_t numSlices = 240 / slice_h; // exactly 6 slices
if (!sprTransAllocated || !sprFadeBAllocated) {
initScreen(true);
return;
}
const uint8_t FRAMES = 10;
// Perceptual crossfade steps (0 to 32)
const uint8_t alpha_steps[FRAMES] = {3, 6, 10, 14, 18, 22, 26, 29, 31, 32};
const int totalPixels = 320 * slice_h;
for (uint8_t f = 0; f < FRAMES; f++) {
uint8_t alpha = alpha_steps[f];
for (uint8_t s = 0; s < numSlices; s++) {
int y_slice = s * slice_h;
sprTrans.fillSprite(TFT_BLACK);
sprFadeB.fillSprite(TFT_BLACK);
if (fromPage == 0)
renderPage0ToSprite(sprTrans, 0, y_slice, slice_h);
else if (fromPage == 1)
renderPage1ToSprite(sprTrans, 0, y_slice, slice_h);
else if (fromPage == 2)
renderPage2ToSprite(sprTrans, 0, y_slice, slice_h);
else
renderPage3ToSprite(sprTrans, 0, y_slice, slice_h);
if (toPage == 0)
renderPage0ToSprite(sprFadeB, 0, y_slice, slice_h);
else if (toPage == 1)
renderPage1ToSprite(sprFadeB, 0, y_slice, slice_h);
else if (toPage == 2)
renderPage2ToSprite(sprFadeB, 0, y_slice, slice_h);
else
renderPage3ToSprite(sprFadeB, 0, y_slice, slice_h);
uint16_t *pA = (uint16_t *)sprTrans.getPointer();
uint16_t *pB = (uint16_t *)sprFadeB.getPointer();
if (pA && pB) {
for (int i = 0; i < totalPixels; i++) {
uint16_t rawA = pA[i];
uint16_t rawB = pB[i];
if (rawA == rawB)
continue;
// Convert byte-swapped format from sprite to RGB565
uint16_t c1 = (rawA >> 8) | (rawA << 8);
uint16_t c2 = (rawB >> 8) | (rawB << 8);
// Fast parallel 32-bit blend of Red/Blue and Green
uint32_t rb1 = c1 & 0xF81F;
uint32_t g1 = c1 & 0x07E0;
uint32_t rb2 = c2 & 0xF81F;
uint32_t g2 = c2 & 0x07E0;
uint32_t rb = ((rb1 * (32 - alpha) + rb2 * alpha) >> 5) & 0xF81F;
uint32_t g = ((g1 * (32 - alpha) + g2 * alpha) >> 5) & 0x07E0;
uint16_t blended = (uint16_t)(rb | g);
pA[i] = (blended >> 8) | (blended << 8);
}
}
// Pagination indicator dots on top slice (4 dots for 4 pages)
if (y_slice == 0) {
uint8_t activeDot = (alpha < 16) ? fromPage : toPage;
sprTrans.fillCircle(139, 2, 2,
(activeDot == 0) ? getThemePrimaryColor() : 0x5AEB);
sprTrans.fillCircle(153, 2, 2,
(activeDot == 1) ? getThemePrimaryColor() : 0x5AEB);
sprTrans.fillCircle(167, 2, 2,
(activeDot == 2) ? getThemePrimaryColor() : 0x5AEB);
sprTrans.fillCircle(181, 2, 2,
(activeDot == 3) ? getThemePrimaryColor() : 0x5AEB);
}
sprTrans.pushSprite(0, y_slice);
yield();
}
yield();
delay(10);
}
sprFadeB.fontLoaded = false;
sprTrans.fontLoaded = false;
}
// Master transition dispatcher based on user configuration
inline void performScreenTransition(bool swipeLeft, int8_t fromPage = -1,
int8_t toPage = -1) {
if (fromPage < 0)
fromPage = currentScreenPage;
if (toPage < 0)
toPage = (fromPage + 1) % 4;
if (transitionType == 1) {
animateFadeTransition(fromPage, toPage);
} else {
animateBlackTransition(toPage);
}
}
/*-------------------*/
// initialize display
void initScreen(bool redrawTFT) {
terminalLineCount = 0;
#ifdef SERIAL_DEBUG
Serial.printf(">>> [DB] initScreen START: page=%d, fullScreenCell=%d, "
"redrawTFT=%d, heap=%u\n",
currentScreenPage, fullScreenCell, redrawTFT,
ESP.getFreeHeap());
#endif
for (int i = 0; i < 4; i++) {
pidList[i] = pidConfig[pidInCell[i]][2]; // get PID to list
pidReadSkip[i] = pidConfig[pidInCell[i]][6].toInt(); // get read skip
pidCurrentSkip[i] = pidReadSkip[i]; // skip for skip reading pid
}
for (int i = 4; i < maxpidIndex; i++) {
pidList[i] = pidConfig[i][2]; // get background PIDs (e.g. 0104)
pidReadSkip[i] = pidConfig[i][6].toInt(); // get read skip
pidCurrentSkip[i] = pidReadSkip[i];
}
if (!redrawTFT) {
// Screen is ALREADY 100% rendered with zero flicker by the double-buffered
// transition engine! ONLY sync tracking variables so live updates continue
// seamlessly without redrawing or blanking!
if (currentScreenPage == 1) {
syncTriTempTrackers();
} else if (currentScreenPage == 2) {
syncGpsClockTrackers();
} else if (currentScreenPage == 3) {
syncGpsNavTrackers();
} else if (currentScreenPage == 0 && fullScreenCell == -1) {
syncPage0Trackers();
}
#ifdef SERIAL_DEBUG
Serial.printf(">>> [DB] initScreen (NO REDRAW) FINISHED OK (heap: %u)\n",
ESP.getFreeHeap());
#endif
return;
}
for (int i = 0; i < maxpidIndex; i++) {
old_data[i] = -999;
}
if (fullScreenCell >= 0 && fullScreenCell < 4) {
#ifdef SERIAL_DEBUG
Serial.println(">>> [DB] initScreen: drawing fullScreenMeter");
#endif
fullScreenMeter(fullScreenCell, pidInCell[fullScreenCell]);
} else if (currentScreenPage == 1) {
#ifdef SERIAL_DEBUG
Serial.println(">>> [DB] initScreen: drawing drawTriTempScreen(0)");
#endif
drawTriTempScreen(0);
} else if (currentScreenPage == 2) {
#ifdef SERIAL_DEBUG
Serial.println(">>> [DB] initScreen: drawing drawGpsClockScreen(0)");
#endif
drawGpsClockScreen(0);
} else if (currentScreenPage == 3) {
#ifdef SERIAL_DEBUG
Serial.println(">>> [DB] initScreen: drawing drawGpsNavScreen(0)");
#endif
drawGpsNavScreen(0);
} else {
#ifdef SERIAL_DEBUG
Serial.println(">>> [DB] initScreen: drawing Page 0 quadrants");
#endif
tft.fillScreen(TFT_BLACK); // clear screen
for (uint8_t c = 0; c < 4; c++) {
uint8_t pid = pidInCell[c];
arcMeter4(c, pid);
}
drawQuadrantDividers();
// Pagination indicator dots [● ○ ○ ○] (Page 1 active)
tft.fillCircle(139, 2, 2, getThemePrimaryColor()); // Page 1 active
tft.fillCircle(153, 2, 2, 0x5AEB); // Page 2 inactive
tft.fillCircle(167, 2, 2, 0x5AEB); // Page 3 inactive
tft.fillCircle(181, 2, 2, 0x5AEB); // Page 4 inactive
}
if (currentScreenPage == 0 && fullScreenCell == -1) {
badgeEcoDrawn = false;
updateEcoBadge(true);
}
#ifdef SERIAL_DEBUG
Serial.printf(">>> [DB] initScreen FINISHED OK (heap: %u)\n",
ESP.getFreeHeap());
#endif
}
/*-------------------------------*/
// get A B from response return in global A B variable; returns true only if
// valid OBD2 data bytes were parsed
bool getAB(String elm_rsp, String currentPid) { // 41 05 2A 3C 01>
A = 0;
B = 0;
if (currentPid == "ATRV") {
return true;
}
if (elm_rsp.indexOf("NO DATA") != -1 || elm_rsp.indexOf("UNABLE") != -1 ||
elm_rsp.indexOf("ERROR") != -1 || elm_rsp.indexOf("STOPPED") != -1 ||
elm_rsp.indexOf("BUS BUSY") != -1 || elm_rsp.indexOf("TIMEOUT") != -1 ||
elm_rsp.indexOf("SEARCHING") != -1 || elm_rsp.indexOf("?") != -1 ||
elm_rsp.indexOf("BUFFER FULL") != -1) {
return false;
}
// Normalize: replace carriage returns, newlines, and prompt chars with spaces
elm_rsp.replace('\r', ' ');
elm_rsp.replace('\n', ' ');
elm_rsp.replace('>', ' ');
// Extract expected PID hex byte (e.g. "0105" -> "05", "010C" -> "0C", "010D"
// -> "0D")
String pidByte =
(currentPid.length() >= 4) ? currentPid.substring(2) : currentPid;
pidByte.toUpperCase();
bool needB = (pidByte == "0C"); // RPM requires both A and B!
// 1. Tokenized search: look for "41" followed by PID byte
String tokens[16];
uint8_t tokenCount = 0;
int startIdx = 0;
while (startIdx < elm_rsp.length() && tokenCount < 16) {
while (startIdx < elm_rsp.length() && elm_rsp.charAt(startIdx) == ' ') {
startIdx++;
}
if (startIdx >= elm_rsp.length())
break;
int endIdx = elm_rsp.indexOf(' ', startIdx);
if (endIdx == -1)
endIdx = elm_rsp.length();
tokens[tokenCount++] = elm_rsp.substring(startIdx, endIdx);
startIdx = endIdx + 1;
}
for (uint8_t i = 0; i < tokenCount; i++) {
tokens[i].toUpperCase();
}
for (uint8_t i = 0; i + 1 < tokenCount; i++) {
if (tokens[i] == "41" && tokens[i + 1] == pidByte) {
if (needB) {
if (i + 3 < tokenCount) {
A = strtol(tokens[i + 2].c_str(), NULL, 16);
B = strtol(tokens[i + 3].c_str(), NULL, 16);
return true;
}
} else {
if (i + 2 < tokenCount) {
A = strtol(tokens[i + 2].c_str(), NULL, 16);
B = 0;
return true;
}
}
}
}
// 2. Compact string fallback (in case adapter sends responses without spaces,
// e.g. "410D32")
String compact = "";
for (int i = 0; i < elm_rsp.length(); i++) {
char c = elm_rsp.charAt(i);
if (c != ' ')
compact += (char)toupper(c);
}
String targetPrefix = "41" + pidByte;
int fIdx = compact.indexOf(targetPrefix);
if (fIdx != -1) {
int dataIdx = fIdx + targetPrefix.length();
if (needB) {
if (dataIdx + 4 <= compact.length()) {
A = strtol(compact.substring(dataIdx, dataIdx + 2).c_str(), NULL, 16);
B = strtol(compact.substring(dataIdx + 2, dataIdx + 4).c_str(), NULL,
16);
return true;
}
} else {
if (dataIdx + 2 <= compact.length()) {
A = strtol(compact.substring(dataIdx, dataIdx + 2).c_str(), NULL, 16);
B = 0;
return true;
}
}
}
// 3. Positional fallback ONLY if tokens[0] == "41" AND tokens[1] == pidByte
if (tokenCount >= 2 && tokens[0] == "41" && tokens[1] == pidByte) {
if (needB && tokenCount >= 4) {
A = strtol(tokens[2].c_str(), NULL, 16);
B = strtol(tokens[3].c_str(), NULL, 16);
return true;
} else if (!needB && tokenCount >= 3) {
A = strtol(tokens[2].c_str(), NULL, 16);
B = 0;
return true;
}
}
return false;
} // getAB
// Update vehicle speed from GPS on quadrant 1 (Velocidad) and fullscreen if
// active
inline void updateGpsSpeedometer() {
if (!isGpsSpeedValid())
return;
float spd = round(getGpsSpeed());
if (spd > 0.0f)
lastActivityTime = millis();
// Only update if integer speed changed or was invalid
static float lastRenderedGpsSpd = -999.0f;
if (spd != lastRenderedGpsSpd || last_valid_data[1] <= -900.0f) {
lastRenderedGpsSpd = spd;
last_valid_data[1] = spd;
int warn = warningValue[3].toInt();
if (fullScreenCell == 1) {
plotFullScreen(1, spd, warn, 0, 160, false, "km/h");
} else if (currentScreenPage == 0 && fullScreenCell == -1) {
plotArc4(1, spd, warn, 0, 160, false, "km/h");
}
}
}
void updateMeter(uint8_t pidNo, String response) { // update parameter on screen
if (pidNo >= maxpidIndex)
return;
// Background PID 4: Carga de Motor (Calculated Engine Load % - PID 0104)
if (pidNo == 4) {
bool valid = getAB(response, pidList[4]);
if (valid) {
ignitionOn = true;
consecutiveEcuFails = 0;
float data = A * 100.0 / 255.0; // Formula 2: Engine Load %
if (data >= 0.0f && data <= 100.0f) {
currentEngineLoad = data;
updateEcoBadge();
}
}
bt_message = "";
return;
}
if (pidNo >= 4)
return;
uint8_t pidIdx = pidInCell[pidNo];
if (pidIdx >= 4)
return;
String label = pidConfig[pidIdx][0];
String unit = pidConfig[pidIdx][1];
uint8_t formula = pidConfig[pidIdx][3].toInt();
int min = pidConfig[pidIdx][4].toInt();
int max = pidConfig[pidIdx][5].toInt();
bool digit = pidConfig[pidIdx][7].toInt();
int warn = warningValue[pidIdx].toInt();
// Contador de fallos consecutivos por cada instrumento (debounce automotriz)
static uint8_t pidFailCount[4] = {0, 0, 0, 0};
bool valid = getAB(response, pidList[pidNo]);
float data = 0.0;
bool isFailed = false;
if (formula == 8) {
// Battery Voltage from ELM327 command "ATRV" (e.g. "14.2V\r\r>")
String s = response;
s.trim();
int vIdx = s.indexOf('V');
if (vIdx == -1)
vIdx = s.indexOf('v');
if (vIdx != -1)
s = s.substring(0, vIdx);
int lastSpace = s.lastIndexOf(' ');
if (lastSpace != -1)
s = s.substring(lastSpace + 1);
int lastCr = s.lastIndexOf('\r');
if (lastCr != -1)
s = s.substring(lastCr + 1);
int lastLf = s.lastIndexOf('\n');
if (lastLf != -1)
s = s.substring(lastLf + 1);
s.trim();
data = s.toFloat();
if (data <= 1.0f) {
isFailed = true;
}
if (!ignitionOn || isFailed) {
plotNoData(pidNo);
bt_message = "";
return;
}
} else {
if (!valid) {
isFailed = true;
}
}
if (isFailed) {
pidFailCount[pidNo]++;
// Debounce automotriz: si un paquete cae por ruido en el bus J1850, NUNCA
// poner en cero la pantalla, simplemente mantener el valor anterior. Solo
// si falla 10 veces seguidas declarar perdida de contacto.
if (pidFailCount[pidNo] >= 10) {
if (formula == 3) {
currentRpm = 0.0f; // Motor detenido / sin RPM
updateEcoBadge();
}
consecutiveEcuFails++;
if (consecutiveEcuFails >= 6) {
ignitionOn = false;
updateEcoBadge();
// Contacto cerrado sostenido: limpiar todos los instrumentos a "---"
// (sin datos)
for (uint8_t c = 0; c < 4; c++) {
plotNoData(c);
}
}
}
bt_message = "";
return; // SALE SIN TOCAR LA PANTALLA NI PONERLA EN CERO
}
// Si la respuesta fue valida, reseteamos contadores de fallo y confirmamos
// contacto puesto
pidFailCount[pidNo] = 0;
consecutiveEcuFails = 0;
ignitionOn = true;
switch (formula) {
case 0:
data = A * 0.145;
break; // psi
case 1:
data = A - 40;
// Filtro de rango realista para refrigerante (-20°C a 140°C)
if (data < -20 || data > 140) {
bt_message = "";
return;
}
tempEngineCoolant = data;
if (currentScreenPage == 1 && fullScreenCell == -1) {
updateTriTempCoolant(data);
}
break; // temp °C (ECT)
case 2:
data = A * 100.0 / 255;
break; // %
case 3:
data = (256 * A + B) / 4.0;
// Filtro de rango realista para RPM (0 a 9000)
if (data < 0 || data > 9000) {
bt_message = "";
return;
}
currentRpm = data;
if (data > 300.0f) {
lastActivityTime = millis();
}
updateEcoBadge();
break; // rpm (ENG SPD)
case 4:
data = (256 * A + B) / 1000.0;
break; // volt (PCM Volt)
case 5:
data = (256 * A + B) / 16.0;
if (data > 150 || data < 0)
data = old_data[pidNo];
break;
case 6:
data = (A * 256 + B) * 5 / 72 - 18;
if (data > 150 || data < 0)
data = old_data[pidNo];
break;
case 7:
data = A; // km/h (PID 010D Vehicle Speed)
if (data > 0.0f)
lastActivityTime = millis();
// If GPS speed is active and locked, prioritize GPS speed over OBD-II
if (isGpsSpeedValid()) {
bt_message = "";
return;
}
break;
case 8:
// Ya parseado y validado arriba en data
break;
default:
return;
}
// Si el motor esta en contacto pero apagado (RPM == 0), mostrar "---" en RPM
// en vez de 0
if (formula == 3 && data <= 0.0f) {
currentRpm = 0.0f;
updateEcoBadge();
plotNoData(pidNo);
bt_message = "";
return;
}
last_valid_data[pidNo] = data;
if (fullScreenCell >= 0 && fullScreenCell < 4) {
if (pidNo == fullScreenCell) {
plotFullScreen(fullScreenCell, data, warn, min, max, digit, unit);
}
} else if (currentScreenPage == 0) {
plotArc4(pidNo, data, warn, min, max, digit, unit);
}
bt_message = ""; // reset
}
/*-------------------------------*/
// Non-blocking alarm sound generator: 4 selectable patterns
uint32_t lastAlarmSoundCycle = 0;
uint8_t alarmSoundStep = 0;
uint32_t alarmSoundStepTime = 0;
void handleAlarmSound(bool anyAlarmActive, bool flashTick = false) {
if (!anyAlarmActive || alarmVolumeLevel == 0) {
if (alarmSoundStep != 0) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 0;
}
return;
}
uint32_t now = millis();
uint16_t duty = VOL_DUTY_TABLE[alarmVolumeLevel > 5 ? 3 : alarmVolumeLevel];
if (alarmSoundType == 0) {
// 0: Chime BMW (Mi7 -> Do7, 2637 Hz -> 2093 Hz cada 1100ms)
if (alarmSoundStep == 0) {
if (now - lastAlarmSoundCycle >= 1100) {
lastAlarmSoundCycle = now;
alarmSoundStep = 1;
alarmSoundStepTime = now;
ledcSetup(buzzerChannel, 2637, 10);
ledcWrite(buzzerChannel, duty);
}
} else if (alarmSoundStep == 1) { // 60ms tono 1
if (now - alarmSoundStepTime >= 60) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 2;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 2) { // 15ms pausa
if (now - alarmSoundStepTime >= 15) {
ledcSetup(buzzerChannel, 2093, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 3;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 3) { // 110ms tono 2
if (now - alarmSoundStepTime >= 110) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 0;
}
}
} else if (alarmSoundType == 1) {
// 1: Mercedes Gong (Re7 -> Fa#7, 2349 Hz -> 2960 Hz cada 1100ms)
if (alarmSoundStep == 0) {
if (now - lastAlarmSoundCycle >= 1100) {
lastAlarmSoundCycle = now;
alarmSoundStep = 1;
alarmSoundStepTime = now;
ledcSetup(buzzerChannel, 2349, 10);
ledcWrite(buzzerChannel, duty);
}
} else if (alarmSoundStep == 1) { // 45ms tono 1
if (now - alarmSoundStepTime >= 45) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 2;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 2) { // 20ms pausa
if (now - alarmSoundStepTime >= 20) {
ledcSetup(buzzerChannel, 2960, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 3;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 3) { // 85ms tono 2
if (now - alarmSoundStepTime >= 85) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 0;
}
}
} else if (alarmSoundType == 2) {
// 2: Audi Cockpit (Do7 -> Mi7 -> Sol7, 2093 -> 2637 -> 3136 Hz cada 1100ms)
if (alarmSoundStep == 0) {
if (now - lastAlarmSoundCycle >= 1100) {
lastAlarmSoundCycle = now;
alarmSoundStep = 1;
alarmSoundStepTime = now;
ledcSetup(buzzerChannel, 2093, 10);
ledcWrite(buzzerChannel, duty);
}
} else if (alarmSoundStep == 1) { // 35ms nota 1
if (now - alarmSoundStepTime >= 35) {
ledcSetup(buzzerChannel, 2637, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 2;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 2) { // 35ms nota 2
if (now - alarmSoundStepTime >= 35) {
ledcSetup(buzzerChannel, 3136, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 3;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 3) { // 65ms nota 3
if (now - alarmSoundStepTime >= 65) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 0;
}
}
} else if (alarmSoundType == 3) {
// 3: Porsche Sport (Doble staccato rapido 3500 Hz cada 900ms)
if (alarmSoundStep == 0) {
if (now - lastAlarmSoundCycle >= 900) {
lastAlarmSoundCycle = now;
alarmSoundStep = 1;
alarmSoundStepTime = now;
ledcSetup(buzzerChannel, 3500, 10);
ledcWrite(buzzerChannel, duty);
}
} else if (alarmSoundStep == 1) { // 30ms pulso 1
if (now - alarmSoundStepTime >= 30) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 2;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 2) { // 25ms pausa
if (now - alarmSoundStepTime >= 25) {
ledcSetup(buzzerChannel, 3500, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 3;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 3) { // 30ms pulso 2
if (now - alarmSoundStepTime >= 30) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 0;
}
}
} else if (alarmSoundType == 4) {
// 4: Casio Clasico (Doble chirp 4096 Hz cada 1000ms)
if (alarmSoundStep == 0) {
if (now - lastAlarmSoundCycle >= 1000) {
lastAlarmSoundCycle = now;
alarmSoundStep = 1;
alarmSoundStepTime = now;
ledcSetup(buzzerChannel, 4096, 10);
ledcWrite(buzzerChannel, duty);
}
} else if (alarmSoundStep == 1) { // 24ms chirp 1
if (now - alarmSoundStepTime >= 24) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 2;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 2) { // 35ms pausa
if (now - alarmSoundStepTime >= 35) {
ledcSetup(buzzerChannel, 4096, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 3;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 3) { // 24ms chirp 2
if (now - alarmSoundStepTime >= 24) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 0;
}
}
} else if (alarmSoundType == 5) {
// 5: Triple Urgente (3 pulsos rapidos 4096 Hz cada 1200ms)
if (alarmSoundStep == 0) {
if (now - lastAlarmSoundCycle >= 1200) {
lastAlarmSoundCycle = now;
alarmSoundStep = 1;
alarmSoundStepTime = now;
ledcSetup(buzzerChannel, 4096, 10);
ledcWrite(buzzerChannel, duty);
}
} else if (alarmSoundStep == 1) { // 22ms pulso 1
if (now - alarmSoundStepTime >= 22) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 2;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 2) { // 25ms pausa
if (now - alarmSoundStepTime >= 25) {
ledcSetup(buzzerChannel, 4096, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 3;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 3) { // 22ms pulso 2
if (now - alarmSoundStepTime >= 22) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 4;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 4) { // 25ms pausa
if (now - alarmSoundStepTime >= 25) {
ledcSetup(buzzerChannel, 4096, 10);
ledcWrite(buzzerChannel, duty);
alarmSoundStep = 5;
alarmSoundStepTime = now;
}
} else if (alarmSoundStep == 5) { // 22ms pulso 3
if (now - alarmSoundStepTime >= 22) {
ledcWrite(buzzerChannel, 0);
alarmSoundStep = 0;
}
}
}
}
// Non-blocking handler to blink/flash arcs and numerical values on alarm (when
// in TFT_RED)
void handleArcAlarmFlash() {
uint32_t now = millis();
bool toggleTick = false;
if (now - lastArcFlashTime >= 250) {
lastArcFlashTime = now;
arcFlashVisible = !arcFlashVisible;
toggleTick = true;
}
bool anyInRedAlarm = false;
if (fullScreenCell >= 0 && fullScreenCell < 4) {
uint8_t cell = fullScreenCell;
if (last_valid_data[cell] != -999) {
uint8_t pidIdx = pidInCell[cell];
int warn = warningValue[pidIdx].toInt();
float data = last_valid_data[cell];
uint16_t col = getMeterColor(pidIdx, data, warn);
bool inRed = (col == TFT_RED);
bool isAlarmActive = alarmEnabled[pidIdx];
if (inRed && isAlarmActive)
anyInRedAlarm = true;
bool digit = pidConfig[pidIdx][7].toInt();
String numStr = String(data, digit);
if (wasInRedAlarm[cell] && !inRed) {
// Exited red alarm: restore steady arc, text, icon, and title
wasInRedAlarm[cell] = false;
drawPidIconHeader(10, 4, pidConfig[pidIdx][2]);
drawFullScreenTitle(pidConfig[pidIdx][0], TFT_WHITE);
int min = pidConfig[pidIdx][4].toInt();
int max = pidConfig[pidIdx][5].toInt();
int newBlocks = calcMeterBlocks(data, min, max);
for (uint8_t i = 0; i < newBlocks; i++) {
drawBarBlock(i, col);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
tft.loadFont(DSEG7_Modern_BoldItalic_80);
tft.setTextColor(getThemeCellColor(cell), TFT_BLACK);
tft.setTextPadding(240);
tft.drawRightString(numStr.c_str(), 312, 148, 1);
tft.setTextPadding(0);
tft.unloadFont();
} else if (inRed) {
wasInRedAlarm[cell] = true;
if (isAlarmActive) {
if (toggleTick) {
if (arcFlashVisible) {
drawPidIconHeader(10, 4, pidConfig[pidIdx][2]);
drawFullScreenTitle(pidConfig[pidIdx][0], TFT_RED);
} else {
clearPidIconHeader();
drawFullScreenTitle(pidConfig[pidIdx][0], 0);
}
int min = pidConfig[pidIdx][4].toInt();
int max = pidConfig[pidIdx][5].toInt();
int newBlocks = calcMeterBlocks(data, min, max);
if (arcFlashVisible) {
for (uint8_t i = 0; i < newBlocks; i++) {
drawBarBlock(i, TFT_RED);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawBarBlock(i, METER_INACTIVE_COLOR);
}
} else {
for (uint8_t i = 0; i < NUM_BAR_BLOCKS; i++) {
drawBarBlock(i, TFT_BLACK);
}
}
}
}
}
}
} else if (currentScreenPage == 0) {
// 4 quadrants mode
for (uint8_t cell = 0; cell < 4; cell++) {
if (last_valid_data[cell] != -999) {
uint8_t pidIdx = pidInCell[cell];
int warn = warningValue[pidIdx].toInt();
float data = last_valid_data[cell];
uint16_t col = getMeterColor(pidIdx, data, warn);
bool inRed = (col == TFT_RED);
bool isAlarmActive = alarmEnabled[pidIdx];
if (inRed && isAlarmActive)
anyInRedAlarm = true;
bool digit = pidConfig[pidIdx][7].toInt();
String numStr = String(data, digit);
int x = cell4_x[cell];
int y = cell4_y[cell];
if (wasInRedAlarm[cell] && !inRed) {
// Exited red alarm: restore steady arc, text, and icon
wasInRedAlarm[cell] = false;
drawPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
int min = pidConfig[pidIdx][4].toInt();
int max = pidConfig[pidIdx][5].toInt();
int newBlocks = calcMeterBlocks(data, min, max);
for (uint8_t i = 0; i < newBlocks; i++) {
drawQuadBarBlock(cell, i, col);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
tft.loadFont(DSEG7_Modern_BoldItalic_34);
tft.setTextColor(getThemeCellColor(cell), TFT_BLACK);
tft.setTextPadding(114);
tft.drawRightString(numStr.c_str(), x + 152, y + 74, 1);
tft.setTextPadding(0);
tft.unloadFont();
} else if (inRed) {
wasInRedAlarm[cell] = true;
if (isAlarmActive) {
if (toggleTick) {
if (arcFlashVisible) {
drawPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
} else {
clearPidIcon(x + 5, y + 3, pidConfig[pidIdx][2]);
}
int min = pidConfig[pidIdx][4].toInt();
int max = pidConfig[pidIdx][5].toInt();
int newBlocks = calcMeterBlocks(data, min, max);
if (arcFlashVisible) {
for (uint8_t i = 0; i < newBlocks; i++) {
drawQuadBarBlock(cell, i, TFT_RED);
}
for (uint8_t i = newBlocks; i < NUM_BAR_BLOCKS; i++) {
drawQuadBarBlock(cell, i, METER_INACTIVE_COLOR);
}
} else {
for (uint8_t i = 0; i < NUM_BAR_BLOCKS; i++) {
drawQuadBarBlock(cell, i, TFT_BLACK);
}
}
}
}
}
}
}
} else {
// Page 1 (Tri-Temperature screen): check alarm conditions for acoustic
// warning without touching screen graphics
for (uint8_t cell = 0; cell < 4; cell++) {
if (last_valid_data[cell] != -999) {
uint8_t pidIdx = pidInCell[cell];
int warn = warningValue[pidIdx].toInt();
float data = last_valid_data[cell];
uint16_t col = getMeterColor(pidIdx, data, warn);
bool inRed = (col == TFT_RED);
bool isAlarmActive = alarmEnabled[pidIdx];
if (inRed && isAlarmActive)
anyInRedAlarm = true;
}
}
}
handleAlarmSound(anyInRedAlarm, toggleTick);
}
// Target and current brightness variables for smooth fade
uint8_t targetBacklight = 255;
float currentBacklight = 255.0f;
uint32_t lastBacklightStepTime = 0;
void updateBacklightFade() {
uint32_t now = millis();
if (now - lastBacklightStepTime >= 15) { // every 15ms step
lastBacklightStepTime = now;
if (abs(currentBacklight - (float)targetBacklight) > 0.5f) {
if (currentBacklight < targetBacklight) {
currentBacklight += 3.0f;
if (currentBacklight > targetBacklight)
currentBacklight = targetBacklight;
} else {
currentBacklight -= 3.0f;
if (currentBacklight < targetBacklight)
currentBacklight = targetBacklight;
}
ledcWrite(backlightChannel, (uint32_t)currentBacklight);
}
}
}
// auto dim backlight function
void autoDim() {
if (brightnessIndex > 0) {
uint8_t manualVal =
BRIGHTNESS_PWM_TABLE[brightnessIndex > 5 ? 5 : brightnessIndex];
if (targetBacklight != manualVal) {
targetBacklight = manualVal;
}
updateBacklightFade();
return;
}
int light = analogRead(LDR_PIN); // read light
if (light > LIGHT_LEVEL) { // low light (dark)
low_count++;
if (low_count > 6) {
low_count = 6;
if (!dim) {
targetBacklight = 15; // smooth fade target: dim
dim = true;
#ifdef SERIAL_DEBUG
Serial.println(F("Backlight -> target dim (15)"));
#endif
}
}
high_count = 0;
} else { // bright light (day)
high_count++;
if (high_count > 6) {
high_count = 6;
if (dim) {
targetBacklight = 255; // smooth fade target: bright
dim = false;
#ifdef SERIAL_DEBUG
Serial.println(F("Backlight -> target bright (255)"));
#endif
}
}
low_count = 0;
}
updateBacklightFade();
} // autodim
//---------------------------
/*############################################*/