Загрузка данных
/*
* Скетч: Часы + Метроном на базе State Machine для ESP32 Super Mini
* Версия для Arduino Core 3.3.x
* Дизайн: Чистый минимализм с фиксированными позициями
* ФИНАЛЬНАЯ ВЕРСИЯ:
* - Компактный инверсный квадрат (не упирается в верхнюю строку)
* - Мини-анимация эквалайзера в такт метроному
*
* Библиотеки для установки:
* - 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 PULSE_DURATION_MS 70 // Длительность отображения пика анимации
// ------------------ Метроном ------------------
#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_TOP 1 // Отступ сверху (минимальный, чтоб не упираться в заголовок)
#define BOX_PAD_Y_BOTTOM 1 // Отступ снизу от базовой линии
// ------------------ Параметры анимации эквалайзера ------------------
#define EQ_BAR_COUNT 4 // Количество полосок
#define EQ_BAR_WIDTH 3 // Ширина одной полоски
#define EQ_BAR_GAP 2 // Зазор между полосками
#define EQ_BAR_MAX_H 10 // Максимальная высота полоски
#define EQ_BASE_Y 58 // Базовая линия эквалайзера (низ полосок)
// ------------------ Дисплей ------------------
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 bool pulseActive = false; // Флаг для анимации эквалайзера
volatile unsigned long pulseStartTime = 0; // Время начала импульса
int metroBPM = DEFAULT_BPM;
int editBPM = DEFAULT_BPM;
hw_timer_t *metroTimer = NULL;
bool screenWokeUpFromMetro = false;
// Флаги для отображения стрелок
bool showUpArrow = false;
bool showDownArrow = false;
// Состояние полосок эквалайзера (высоты в пикселях)
int eqBars[EQ_BAR_COUNT] = {0, 0, 0, 0};
// ================== ПРЕРЫВАНИЕ МЕТРОНОМА ==================
void IRAM_ATTR onMetronomeBeat() {
if (metroRunning) {
metroBeatFlag = true;
pulseActive = true;
pulseStartTime = millis();
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);
}
/*
* КОМПАКТНАЯ функция выделения текста инверсией
*
* Исправлено: верхний отступ уменьшен, чтобы квадрат не упирался в заголовок
* X, Y — базовые координаты текста (НИКОГДА не меняются)
*/
void drawHighlightedText(int x, int y, const char* str, bool highlighted) {
if (highlighted) {
// Получаем реальные размеры шрифта
int w = u8g2.getStrWidth(str);
int h = u8g2.getMaxCharHeight();
// Компактные координаты прямоугольника
int boxX = x - BOX_PAD_X;
int boxW = w + BOX_PAD_X * 2;
// Y: минимальный отступ сверху (1px), чтобы макушки были почти вплотную к краю
int boxY = y - h - BOX_PAD_Y_TOP;
int boxH = h + BOX_PAD_Y_TOP + BOX_PAD_Y_BOTTOM;
// 1. Белый фон
u8g2.setDrawColor(1);
u8g2.drawBox(boxX, boxY, boxW, boxH);
// 2. Чёрный текст на том же месте
u8g2.setDrawColor(0);
u8g2.drawStr(x, y, str);
// 3. Сброс цвета
u8g2.setDrawColor(1);
} else {
// Обычный белый текст
u8g2.setDrawColor(1);
u8g2.drawStr(x, y, str);
}
}
/*
* Ретро-анимация эквалайзера (Nokia-стиль)
* Рисует 4 вертикальные пиксельные полоски в нижней части экрана
*/
void drawEqualizer() {
if (!metroRunning) return;
// Вычисляем общую ширину эквалайзера для центрирования
int totalW = EQ_BAR_COUNT * EQ_BAR_WIDTH + (EQ_BAR_COUNT - 1) * EQ_BAR_GAP;
int startX = (128 - totalW) / 2;
for (int i = 0; i < EQ_BAR_COUNT; i++) {
int barX = startX + i * (EQ_BAR_WIDTH + EQ_BAR_GAP);
int barH = eqBars[i];
if (barH > 0) {
// Рисуем залитую полоску
u8g2.setDrawColor(1);
u8g2.drawBox(barX, EQ_BASE_Y - barH, EQ_BAR_WIDTH, barH);
} else {
// Рисуем точку (базовый уровень)
u8g2.setDrawColor(1);
u8g2.drawPixel(barX + 1, EQ_BASE_Y - 1);
}
}
}
/*
* Обновление состояния эквалайзера
* Вызывается в loop() для анимации падения полосок
*/
void updateEqualizer() {
unsigned long now = millis();
if (pulseActive) {
// В момент удара — все полоски на максимум
for (int i = 0; i < EQ_BAR_COUNT; i++) {
eqBars[i] = EQ_BAR_MAX_H;
}
// Сбрасываем флаг через короткое время
if (now - pulseStartTime >= PULSE_DURATION_MS) {
pulseActive = false;
}
} else {
// Плавное падение полосок (дискретное, в стиле ретро)
for (int i = 0; i < EQ_BAR_COUNT; i++) {
if (eqBars[i] > 0) {
// Каждая полоска падает со своей скоростью
int decaySpeed = 1 + (i % 3); // 1, 2, 3, 1 - разная скорость падения
eqBars[i] = max(0, eqBars[i] - decaySpeed);
}
}
}
}
// Главная функция отрисовки часов
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;
// Сбрасываем эквалайзер
for (int i = 0; i < EQ_BAR_COUNT; i++) eqBars[i] = 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;
for (int i = 0; i < EQ_BAR_COUNT; i++) eqBars[i] = 0;
currentState = METRO_EDIT;
resetActivityTimer();
} else {
metroRunning = false;
for (int i = 0; i < EQ_BAR_COUNT; i++) eqBars[i] = 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) {
for (int i = 0; i < EQ_BAR_COUNT; i++) eqBars[i] = 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 h = u8g2.getMaxCharHeight();
int textX = (128 - w) / 2;
int centerY = TEXT_Y_CENTER - h / 2 + 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");
// BPM на одной линии
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;
// Число BPM
u8g2.setFont(u8g2_font_logisoso24_tr);
u8g2.setDrawColor(1);
u8g2.drawStr(startX, TEXT_Y_CENTER, buf);
// "BPM" на той же линии
u8g2.setFont(u8g2_font_profont12_mf);
u8g2.drawStr(startX + w + 4,