Загрузка данных


/*
 * Скетч: Часы + Метроном на базе State Machine для ESP32 Super Mini
 * Версия для Arduino Core 3.3.x
 * 
 * ФИНАЛЬНАЯ ВЕРСИЯ:
 * - Идеальная геометрия инверсных квадратов (getAscent/getDescent)
 * - Анимация "аварийных ламп" по краям дисплея с эффектом накаливания
 * 
 * Библиотеки для установки:
 * - U8g2 (by oliver)
 * - ESP32Time (by fbiego)
 * 
 * Аппаратная конфигурация:
 * - OLED 0.96" I2C: SCL=4, SDA=3
 * - Кнопки: UP=1, DOWN=0 (INPUT_PULLUP, активный LOW)
 */

#include <Wire.h>
#include <U8g2lib.h>
#include <ESP32Time.h>

// ================== НАСТРАИВАЕМЫЕ ПАРАМЕТРЫ ==================
#define TILTED_CLOCK true

// ------------------ Пины ------------------
#define PIN_BTN_UP     1
#define PIN_BTN_DOWN   0
#define PIN_LED        2
#define PIN_BUZZER     10

// ------------------ Тайминги ------------------
#define SHORT_PRESS_MAX     1000
#define LONG_PRESS_MIN      2000
#define BOTH_PRESS_WINDOW   200
#define SLEEP_TIMEOUT       5000
#define SLEEP_ANIM_FRAMES   5
#define SLEEP_ANIM_DELAY    15
#define HAZARD_FADE_SPEED   35      // Задержка между шагами затухания (мс)

// ------------------ Метроном ------------------
#define DEFAULT_BPM     100
#define MIN_BPM         30
#define MAX_BPM         250

// ------------------ Константы позиционирования ------------------
#define TEXT_Y_CENTER       48      // Базовая линия для крупного текста (Logisoso24)
#define HELP_TEXT_Y         62      // Позиция нижних подсказок

// ------------------ Параметры инверсного квадрата ------------------
#define BOX_PAD_X           2       // Боковой отступ
#define BOX_PAD_Y           2       // Вертикальный отступ (компактный)

// ------------------ Дисплей ------------------
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* clock=*/ 4, /* data=*/ 3);
ESP32Time rtc;

// ================== КОНЕЧНЫЙ АВТОМАТ ==================
enum DeviceState : uint8_t {
    SHOW_TIME,
    SHOW_TIME_SLEEP,
    TIME_PICK,
    TIME_EDIT,
    METRO_SHOW,
    METRO_EDIT
} currentState = SHOW_TIME;

// ================== ПЕРЕМЕННЫЕ КНОПОК ==================
struct ButtonState {
    bool pressed = false;
    unsigned long pressStartTime = 0;
    bool longPressTriggered = false;
    bool shortPressReady = false;
};

ButtonState btnUp, btnDown;
unsigned long lastActivityTime = 0;

// ================== ПЕРЕМЕННЫЕ ВРЕМЕНИ ==================
int currentHour = 12, currentMinute = 0;
bool editMode = 0; // 0 - часы, 1 - минуты

// ================== ПЕРЕМЕННЫЕ МЕТРОНОМА ==================
volatile bool metroRunning = false;
volatile bool metroBeatFlag = false;
volatile int hazardGlow = 0;              // Фаза свечения лампы: 0-4
unsigned long lastHazardUpdate = 0;       // Таймер затухания анимации
int metroBPM = DEFAULT_BPM;
int editBPM = DEFAULT_BPM;
hw_timer_t *metroTimer = NULL;
bool screenWokeUpFromMetro = false;

// Флаги для отображения стрелок
bool showUpArrow = false;
bool showDownArrow = false;

// ================== ПРЕРЫВАНИЕ МЕТРОНОМА ==================
void IRAM_ATTR onMetronomeBeat() {
    if (metroRunning) {
        metroBeatFlag = true;
        hazardGlow = 4;  // Мгновенный пик яркости "ламп"
        digitalWrite(PIN_LED, !digitalRead(PIN_LED));
    }
}

void updateMetronomeTimer() {
    if (metroTimer) {
        unsigned long period = (60 * 1000000UL) / metroBPM;
        if (period < 1000) period = 1000;
        timerAlarm(metroTimer, period, true, 0);
    }
}

// ================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==================
void drawHelperText() {
    u8g2.setFont(u8g2_font_5x8_tr);
    u8g2.setDrawColor(1);
    u8g2.drawStr(2, HELP_TEXT_Y, "P 1");
    u8g2.drawStr(45, HELP_TEXT_Y, "H 10");
    u8g2.drawStr(95, HELP_TEXT_Y, "BOTH OK");
}

// Рисует стрелку вверх (справа от числа)
void drawUpArrow(int x, int y) {
    u8g2.setDrawColor(1);
    u8g2.drawTriangle(x, y - 8, x - 6, y + 2, x + 6, y + 2);
}

// Рисует стрелку вниз (слева от числа)
void drawDownArrow(int x, int y) {
    u8g2.setDrawColor(1);
    u8g2.drawTriangle(x, y + 8, x - 6, y - 2, x + 6, y - 2);
}

/*
 * ИДЕАЛЬНАЯ функция выделения текста инверсией
 * Использует getAscent()/getDescent() для точной геометрии
 * X, Y — базовые координаты текста (НИКОГДА не меняются)
 */
void drawHighlightedText(int x, int y, const char* str, bool highlighted) {
    int textWidth = u8g2.getStrWidth(str);
    int fontAscent = u8g2.getAscent();    // Высота от baseline вверх
    int fontDescent = u8g2.getDescent();  // Высота от baseline вниз
    int fontHeight = fontAscent - fontDescent;
    
    if (highlighted) {
        // 1. Белый фон — компактный, симметричный
        u8g2.setDrawColor(1);
        u8g2.drawBox(x - BOX_PAD_X, 
                     y - fontAscent - BOX_PAD_Y, 
                     textWidth + (BOX_PAD_X * 2), 
                     fontHeight + (BOX_PAD_Y * 2));
        
        // 2. Чёрный текст
        u8g2.setDrawColor(0);
        u8g2.drawStr(x, y, str);
        
        // 3. Сброс цвета
        u8g2.setDrawColor(1);
    } else {
        // Обычный белый текст
        u8g2.setDrawColor(1);
        u8g2.drawStr(x, y, str);
    }
}

/*
 * Анимация "аварийных ламп" по краям дисплея
 * Эффект тепловой инерции нити накала
 * Рисуется ТОЛЬКО в режиме METRO_SHOW при запущенном метрономе
 */
void drawHazardLamps() {
    if (!metroRunning || hazardGlow == 0) return;
    
    u8g2.setDrawColor(1);
    
    switch(hazardGlow) {
        case 4: // Пик удара: широкие блоки по краям
            u8g2.drawBox(0, 0, 5, 64);       // Левый край
            u8g2.drawBox(123, 0, 5, 64);     // Правый край
            break;
            
        case 3: // Начало затухания: полосы сужаются
            u8g2.drawBox(0, 4, 3, 56);
            u8g2.drawBox(125, 4, 3, 56);
            break;
            
        case 2: // Лампа остывает: тонкие линии
            u8g2.drawVLine(0, 12, 40);
            u8g2.drawVLine(1, 12, 40);
            u8g2.drawVLine(126, 12, 40);
            u8g2.drawVLine(127, 12, 40);
            break;
            
        case 1: // Финальный тлен нити накала
            u8g2.drawVLine(0, 22, 20);
            u8g2.drawVLine(127, 22, 20);
            break;
    }
}

// Главная функция отрисовки часов
void drawMainClock() {
    int h = rtc.getHour(true);
    int m = rtc.getMinute();
    int s = rtc.getSecond();
    char buf[6];
    sprintf(buf, "%02d:%02d", h, m);

    if (TILTED_CLOCK) {
        u8g2.setFont(u8g2_font_logisoso30_tn);
        int x = 10, y = 65;
        for(int i = 0; i < 5; i++) {
            char c[2] = {buf[i], '\0'};
            u8g2.drawStr(x + i*20, y - i*8, c);
        }
    } else {
        u8g2.setFont(u8g2_font_logisoso32_tn);
        u8g2.drawStr(10, 45, buf);
    }

    u8g2.setFont(u8g2_font_profont12_mf);
    char buf12[12];
    int h12 = (h % 12 == 0) ? 12 : h % 12;
    sprintf(buf12, "%02d:%02d:%02d", h12, m, s);
    u8g2.drawStr(70, 60, buf12);
}

// Анимация засыпания дисплея
void playSleepAnimation() {
    for (int i = 0; i < SLEEP_ANIM_FRAMES; i++) {
        u8g2.clearBuffer();
        drawMainClock();
        int contrast = 255 - (i * 50);
        if (contrast < 0) contrast = 0;
        u8g2.setContrast(contrast);
        u8g2.sendBuffer();
        delay(SLEEP_ANIM_DELAY);
    }
    u8g2.setPowerSave(1);
    u8g2.setContrast(255);
}

void resetActivityTimer() {
    lastActivityTime = millis();
    if (currentState == SHOW_TIME_SLEEP) {
        screenWokeUpFromMetro = false;
    }
    if (currentState == SHOW_TIME_SLEEP && !metroRunning) {
        currentState = SHOW_TIME;
        u8g2.setPowerSave(0);
    }
}

void enterSleepMode() {
    if (currentState == SHOW_TIME) {
        playSleepAnimation();
        currentState = SHOW_TIME_SLEEP;
    }
}

void saveTimeToRTC(int h, int m) {
    rtc.setTime(0, m, h, 1, 1, 2024);
    currentHour = h;
    currentMinute = m;
}

// ================== ОБРАБОТЧИКИ НАЖАТИЙ ==================
void onLongPress(byte btn) {
    if (currentState == SHOW_TIME || currentState == SHOW_TIME_SLEEP) {
        if (btn == PIN_BTN_UP) {
            currentHour = rtc.getHour(true);
            currentMinute = rtc.getMinute();
            editMode = 0;
            currentState = TIME_PICK;
            resetActivityTimer();
        } else {
            currentState = METRO_SHOW;
            metroRunning = false;
            hazardGlow = 0;
            resetActivityTimer();
        }
    } 
    else if (currentState == TIME_PICK || currentState == TIME_EDIT) {
        if (btn == PIN_BTN_UP) {
            saveTimeToRTC(currentHour, currentMinute);
            currentState = SHOW_TIME;
        }
    } 
    else if (currentState == METRO_SHOW) {
        if (btn == PIN_BTN_UP) {
            editBPM = metroBPM;
            metroRunning = false;
            hazardGlow = 0;
            currentState = METRO_EDIT;
            resetActivityTimer();
        } else {
            metroRunning = false;
            hazardGlow = 0;
            currentState = SHOW_TIME;
        }
    }
}

void onShortPress(byte btn) {
    if (currentState == TIME_PICK) {
        editMode = !editMode;
    }
    else if (currentState == TIME_EDIT) {
        if (btn == PIN_BTN_UP) {
            if (editMode == 0) currentHour = (currentHour + 1) % 24;
            else currentMinute = (currentMinute + 1) % 60;
        } else {
            if (editMode == 0) currentHour = (currentHour - 1 + 24) % 24;
            else currentMinute = (currentMinute - 1 + 60) % 60;
        }
    }
    else if (currentState == METRO_SHOW) {
        metroRunning = !metroRunning;
        digitalWrite(PIN_LED, metroRunning ? LOW : HIGH);
        if (!metroRunning) hazardGlow = 0;
    }
    else if (currentState == METRO_EDIT) {
        if (btn == PIN_BTN_UP) editBPM = constrain(editBPM + 1, MIN_BPM, MAX_BPM);
        else editBPM = constrain(editBPM - 1, MIN_BPM, MAX_BPM);
    }
}

void onHoldPress(byte btn) {
    if (currentState == TIME_EDIT) {
        if (btn == PIN_BTN_UP) {
            if (editMode == 0) currentHour = (currentHour + 10) % 24;
            else currentMinute = (currentMinute + 10) % 60;
        } else {
            if (editMode == 0) currentHour = (currentHour - 10 + 24) % 24;
            else currentMinute = (currentMinute - 10 + 60) % 60;
        }
    }
    else if (currentState == METRO_EDIT) {
        if (btn == PIN_BTN_UP) editBPM = constrain(editBPM + 10, MIN_BPM, MAX_BPM);
        else editBPM = constrain(editBPM - 10, MIN_BPM, MAX_BPM);
    }
}

void onBothHold() {
    if (currentState == TIME_PICK) {
        currentState = TIME_EDIT;
    }
    else if (currentState == TIME_EDIT) {
        currentState = TIME_PICK;
    }
    else if (currentState == METRO_EDIT) {
        metroBPM = editBPM;
        updateMetronomeTimer();
        currentState = METRO_SHOW;
    }
}

// ================== ОБРАБОТЧИК КНОПОК ==================
void handleButtons() {
    unsigned long now = millis();
    bool sUp = !digitalRead(PIN_BTN_UP);
    bool sDown = !digitalRead(PIN_BTN_DOWN);

    if (currentState == TIME_EDIT || currentState == METRO_EDIT) {
        showUpArrow = sUp && !sDown;
        showDownArrow = sDown && !sUp;
    } else {
        showUpArrow = false;
        showDownArrow = false;
    }

    if (currentState == SHOW_TIME_SLEEP) {
        if (sUp || sDown) {
            if (metroRunning) {
                currentState = METRO_SHOW;
                screenWokeUpFromMetro = true;
            } else {
                currentState = SHOW_TIME;
            }
            u8g2.setPowerSave(0);
            u8g2.setContrast(255);
            lastActivityTime = now;
            while(!digitalRead(PIN_BTN_UP) && !digitalRead(PIN_BTN_DOWN)) delay(5);
            return;
        }
        return;
    }

    // Кнопка ВВЕРХ
    if (sUp) {
        if (!btnUp.pressed) {
            btnUp.pressed = true;
            btnUp.pressStartTime = now;
            btnUp.longPressTriggered = false;
            btnUp.shortPressReady = false;
            lastActivityTime = now;
        }
        unsigned long dur = now - btnUp.pressStartTime;
        if (!btnUp.longPressTriggered) {
            if (sDown && dur >= 1000) {
                onBothHold();
                btnUp.longPressTriggered = true;
                btnDown.longPressTriggered = true;
            }
            else if (dur >= LONG_PRESS_MIN) {
                onLongPress(PIN_BTN_UP);
                btnUp.longPressTriggered = true;
            }
            else if (dur >= 1000 && (currentState == TIME_EDIT || currentState == METRO_EDIT)) {
                onHoldPress(PIN_BTN_UP);
                btnUp.longPressTriggered = true;
            }
        }
    } else {
        if (btnUp.pressed) {
            if (!btnUp.longPressTriggered && (now - btnUp.pressStartTime > 50)) {
                onShortPress(PIN_BTN_UP);
            }
            btnUp.pressed = false;
        }
    }

    // Кнопка ВНИЗ
    if (sDown) {
        if (!btnDown.pressed) {
            btnDown.pressed = true;
            btnDown.pressStartTime = now;
            btnDown.longPressTriggered = false;
            btnDown.shortPressReady = false;
            lastActivityTime = now;
        }
        unsigned long dur = now - btnDown.pressStartTime;
        if (!btnDown.longPressTriggered) {
            if (sUp && dur >= 1000) {
                onBothHold();
                btnUp.longPressTriggered = true;
                btnDown.longPressTriggered = true;
            }
            else if (dur >= LONG_PRESS_MIN) {
                onLongPress(PIN_BTN_DOWN);
                btnDown.longPressTriggered = true;
            }
            else if (dur >= 1000 && (currentState == TIME_EDIT || currentState == METRO_EDIT)) {
                onHoldPress(PIN_BTN_DOWN);
                btnDown.longPressTriggered = true;
            }
        }
    } else {
        if (btnDown.pressed) {
            if (!btnDown.longPressTriggered && (now - btnDown.pressStartTime > 50)) {
                onShortPress(PIN_BTN_DOWN);
            }
            btnDown.pressed = false;
        }
    }

    if (screenWokeUpFromMetro && currentState == METRO_SHOW) {
        screenWokeUpFromMetro = false;
        btnUp.longPressTriggered = true;
        btnDown.longPressTriggered = true;
    }
}

// ================== ОТРИСОВКА ДИСПЛЕЯ ==================
void drawUI() {
    u8g2.clearBuffer();
    
    char buf[6];
    
    switch (currentState) {
        case SHOW_TIME:
            drawMainClock();
            break;

        case SHOW_TIME_SLEEP:
            break;

        case TIME_PICK: {
            u8g2.setFont(u8g2_font_profont12_mf);
            u8g2.setDrawColor(1);
            u8g2.drawStr(2, 12, "SET TIME");
            u8g2.drawStr(85, 12, editMode == 0 ? "HOUR" : "MIN");
            
            char hourStr[3], minStr[3];
            sprintf(hourStr, "%02d", currentHour);
            sprintf(minStr, "%02d", currentMinute);
            
            u8g2.setFont(u8g2_font_logisoso24_tr);
            
            int hourW = u8g2.getStrWidth(hourStr);
            int colonW = u8g2.getStrWidth(":");
            int minW = u8g2.getStrWidth(minStr);
            
            int gap = 4;
            int totalW = hourW + gap + colonW + gap + minW;
            int startX = (128 - totalW) / 2;
            
            int hourX = startX;
            int colonX = startX + hourW + gap;
            int minX = startX + hourW + gap + colonW + gap;
            
            drawHighlightedText(hourX, TEXT_Y_CENTER, hourStr, editMode == 0);
            
            u8g2.setDrawColor(1);
            u8g2.drawStr(colonX, TEXT_Y_CENTER, ":");
            
            drawHighlightedText(minX, TEXT_Y_CENTER, minStr, editMode == 1);
            
            u8g2.setFont(u8g2_font_5x8_tr);
            u8g2.setDrawColor(1);
            u8g2.drawStr(35, HELP_TEXT_Y, "BOTH OK to edit");
            break;
        }

        case TIME_EDIT: {
            u8g2.setFont(u8g2_font_profont12_mf);
            u8g2.setDrawColor(1);
            u8g2.drawStr(2, 12, "CHANGES...");
            u8g2.drawStr(85, 12, editMode == 0 ? "HOUR" : "MIN");
            
            if (editMode == 0) {
                sprintf(buf, "%02d", currentHour);
            } else {
                sprintf(buf, "%02d", currentMinute);
            }
            
            u8g2.setFont(u8g2_font_logisoso24_tr);
            int w = u8g2.getStrWidth(buf);
            int textX = (128 - w) / 2;
            int centerY = TEXT_Y_CENTER - u8g2.getAscent() / 2;
            
            if (showUpArrow) {
                drawUpArrow(textX + w + 12, centerY);
            }
            if (showDownArrow) {
                drawDownArrow(textX - 8, centerY);
            }
            
            drawHighlightedText(textX, TEXT_Y_CENTER, buf, true);
            drawHelperText();
            break;
        }

        case METRO_SHOW: {
            u8g2.setFont(u8g2_font_profont12_mf);
            u8g2.setDrawColor(1);
            u8g2.drawStr(30, 12, "METRONOME");
            
            // Аварийные лампы по краям (рисуем ДО текста, чтобы не перекрывали)
            drawHazardLamps();
            
            u8g2.setFont(u8g2_font_logisoso24_tr);
            sprintf(buf, "%d", metroBPM);
            int w = u8g2.getStrWidth(buf);
            
            u8g2.setFont(u8g2_font_profont12_mf);
            int bpmW = u8g2.getStrWidth("BPM");
            int totalW = w + 4 + bpmW;
            int startX = (128 - totalW) / 2;
            
            u8g2.setFont(u8g2_font_logisoso24_tr);
            u8g2.setDrawColor(1);
            u8g2.drawStr(startX, TEXT_Y_CENTER, buf);
            
            u8g2.setFont(u8g2_font_profont12_mf);
            u8g2.drawStr(startX + w + 4, TEXT_Y_CENTER, "BPM");
            
            u8g2.setFont(u8g2_font_6x10_tr);
            if (metroRunning) {
                u8g2.drawStr(35, HELP_TEXT_Y, "> STOP");
            } else {
                u8g2.drawStr(30, HELP_TEXT_Y, "START?");
            }
            break;
        }

        case METRO_EDIT: {
            u8g2.setFont(u8g2_font_profont12_mf);
            u8g2.setDrawColor(1);
            u8g2.drawStr(2, 12, "CHANGES...");
            u8g2.drawStr(90, 12, "BPM");
            
            u8g2.setFont(u8g2_font_logisoso24_tr);
            sprintf(buf, "%d", editBPM);
            int w = u8g2.getStrWidth(buf);
            int textX = (128 - w) / 2;
            int centerY = TEXT_Y_CENTER - u8g2.getAscent() / 2;
            
            if (showUpArrow) {
                drawUpArrow(textX + w + 12, centerY);
            }
            if (showDownArrow) {
                drawDownArrow(textX - 8, centerY);
            }
            
            drawHighlightedText(textX, TEXT_Y_CENTER, buf, true);
            drawHelperText();
            break;
        }
    }

    u8g2.sendBuffer();
}

// ================== SETUP ==================
void setup() {
    Serial.begin(115200);
    Wire.begin(3, 4);
    u8g2.begin();
    u8g2.setFontMode(1);
    u8g2.setContrast(255);

    pinMode(PIN_BTN_UP, INPUT_PULLUP);
    pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
    pinMode(PIN_LED, OUTPUT);
    digitalWrite(PIN_LED, HIGH);

    rtc.setTime(0, 0, 12, 1, 1, 2024);

    metroTimer = timerBegin(1000000);
    timerAttachInterrupt(metroTimer, &onMetronomeBeat);
    timerAlarm(metroTimer, (60 * 1000000UL) / DEFAULT_BPM, true, 0);

    lastActivityTime = millis();
    lastHazardUpdate = millis();
}

// ================== LOOP ==================
void loop() {
    handleButtons();
    
    // Логика затухания аварийных ламп (каждые 35 мс)
    if (metroRunning) {
        unsigned long now = millis();
        if (now - lastHazardUpdate >= HAZARD_FADE_SPEED) {
            lastHazardUpdate = now;
            if (hazardGlow > 0) {
                hazardGlow--;
            }
        }
    }

    unsigned long now = millis();
    if ((currentState == SHOW_TIME) && 
        (now - lastActivityTime > SLEEP_TIMEOUT)) {
        enterSleepMode();
    }

    if (metroBeatFlag) {
        metroBeatFlag = false;
    }

    if (currentState != SHOW_TIME_SLEEP) {
        drawUI();
    }

    delay(10);
}