Загрузка данных
/*
* Скетч: Часы + Метроном на базе 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);
}