Загрузка данных
// ============================================================
// FIRMWARE: Clock + Metronome (ESP32 Super Mini)
// Libraries required:
// 1. U8g2 — by olikraus (Менеджер библиотек: "U8g2")
// 2. ESP32Time — by fbiego (Менеджер библиотек: "ESP32Time")
// Built-in (no install needed):
// Wire.h, math.h, esp32-hal-timer.h
// ============================================================
#include <Wire.h>
#include <U8g2lib.h>
#include <math.h>
#include <ESP32Time.h>
#include "esp32-hal-timer.h"
// ── TILTED CLOCK TOGGLE ──────────────────────────────────────
#define TILTED_CLOCK true
// ── HARDWARE PINS ────────────────────────────────────────────
#define BTN_UP 2
#define BTN_DOWN 3
#define LED_PIN 9
// ── DISPLAY ──────────────────────────────────────────────────
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
// ── TIMING CONSTANTS ─────────────────────────────────────────
const unsigned long DEBOUNCE_MS = 40; // button debounce
const unsigned long HOLD_INCREMENT_INTERVAL_MS = 200; // interval for +10/-10 when holding
const unsigned long LONG_PRESS_MS = 2000; // long press (mode switch)
const unsigned long BOTH_OK_HOLD_MS = 2000; // BOTH OK threshold
const unsigned long BOTH_PRESS_WINDOW= 200; // max delay between two button presses for combo
const unsigned long LED_PULSE_MS = 35; // LED on-time per beat
const unsigned long SLEEP_TIMEOUT_MS = 5000; // screen-off timeout
// ── RTC ──────────────────────────────────────────────────────
ESP32Time rtc;
// ── METRONOME HARDWARE TIMER ─────────────────────────────────
hw_timer_t * timer = NULL;
volatile bool metroBeat = false;
// ── STATE MACHINE ────────────────────────────────────────────
enum State : uint8_t {
SLEEP,
SHOW_TIME,
TIME_PICK, // select HOUR or MIN
TIME_EDIT, // change the selected value
METRO_SHOW, // metronome main screen
METRO_EDIT // BPM adjustment
};
State state = SHOW_TIME;
byte editMode = 0; // 0 = HOUR, 1 = MIN
unsigned long lastActivityTime = 0;
// ── BUTTON STRUCT ────────────────────────────────────────────
struct Button {
byte pin;
bool isPressed; // current stable state
bool lastIsPressed; // previous stable state
unsigned long pressStartTime;
bool longPressFired; // long press action already triggered
bool holdIncrementFired; // hold +10 action already triggered
unsigned long lastHoldIncrementTime; // last time +10 was triggered
};
Button upBtn = {BTN_UP, false, false, 0, false, false, 0};
Button downBtn = {BTN_DOWN, false, false, 0, false, false, 0};
// ── GLOBAL METRONOME STATE ───────────────────────────────────
int bpm = 100;
bool metroRunning = false;
bool ledPulse = false;
unsigned long ledOffAt = 0;
// ─────────────────────────────────────────────────────────────
// UTILITY: debounced button update
// ─────────────────────────────────────────────────────────────
void updateButtonState(Button &b) {
b.lastIsPressed = b.isPressed;
bool rawState = !digitalRead(b.pin); // Inverted for INPUT_PULLUP
static unsigned long lastChangeTime[2] = {0, 0}; // 0 for up, 1 for down
int btnIndex = (b.pin == BTN_UP) ? 0 : 1;
if (rawState != b.isPressed) {
if (millis() - lastChangeTime[btnIndex] > DEBOUNCE_MS) {
b.isPressed = rawState;
lastChangeTime[btnIndex] = millis();
}
}
}
// ─────────────────────────────────────────────────────────────
// UTILITY: draw inverse text box
// ─────────────────────────────────────────────────────────────
void drawInverseTextBox(int x, int y, const char *txt, const uint8_t *font) {
u8g2.setFont(font);
int w = u8g2.getStrWidth(txt);
int fh = u8g2.getAscent() - u8g2.getDescent();
u8g2.drawBox(x, y - fh + 2, w + 6, fh + 4);
u8g2.setDrawColor(0);
u8g2.drawStr(x + 3, y, txt);
u8g2.setDrawColor(1);
}
// ─────────────────────────────────────────────────────────────
// UTILITY: draw text rotated around its left baseline origin
// ─────────────────────────────────────────────────────────────
void drawRotatedText(int x, int y, const char *text,
float angle_deg, const uint8_t *font) {
u8g2.setFont(font);
float rad = angle_deg * (float)M_PI / 180.0f;
float cosA = cos(rad);
float sinA = sin(rad);
int curX = 0;
for (const char *p = text; *p; p++) {
char c[2] = {*p, '\0'};
int rx = x + (int)(curX * cosA);
int ry = y - (int)(curX * sinA);
u8g2.setCursor(rx, ry);
u8g2.print(*p);
curX += u8g2.getStrWidth(c) + 1;
}
}
// ─────────────────────────────────────────────────────────────
// METRONOME TIMER ISR
// ─────────────────────────────────────────────────────────────
void IRAM_ATTR onTimer() {
metroBeat = true;
}
// ─────────────────────────────────────────────────────────────
// STATE TRANSITIONS
// ─────────────────────────────────────────────────────────────
void gotoShowTime() {
state = SHOW_TIME;
lastActivityTime = millis();
}
void goSleep() {
state = SLEEP;
u8g2.clearBuffer();
u8g2.sendBuffer();
digitalWrite(LED_PIN, LOW);
}
// ─────────────────────────────────────────────────────────────
// BUTTON ACTION HANDLERS
// ─────────────────────────────────────────────────────────────
void handleShortPress(byte btn) {
lastActivityTime = millis();
if (state == TIME_PICK) {
editMode = (editMode == 0) ? 1 : 0; // toggle HOUR/MIN selection
} else if (state == TIME_EDIT) {
if (btn == BTN_UP) {
rtc.setTime(rtc.getSecond(), rtc.getMinute(), (rtc.getHour() + 1) % 24, rtc.getDay(), rtc.getMonth(), rtc.getYear());
} else { // BTN_DOWN
rtc.setTime(rtc.getSecond(), rtc.getMinute(), (rtc.getHour() + 23) % 24, rtc.getDay(), rtc.getMonth(), rtc.getYear());
}
} else if (state == METRO_SHOW) {
metroRunning = !metroRunning;
if (metroRunning) {
timerAlarmEnable(timer);
} else {
timerAlarmDisable(timer);
digitalWrite(LED_PIN, LOW);
ledPulse = false;
}
} else if (state == METRO_EDIT) {
if (btn == BTN_UP) bpm = min(220, bpm + 1);
else bpm = max(40, bpm - 1);
timerAlarmWrite(timer, 60000000ULL / bpm, true); // Update timer for new BPM
}
}
void handleHoldIncrement(byte btn) {
lastActivityTime = millis();
if (state == TIME_EDIT) {
if (btn == BTN_UP) {
rtc.setTime(rtc.getSecond(), rtc.getMinute(), (rtc.getHour() + 10) % 24, rtc.getDay(), rtc.getMonth(), rtc.getYear());
} else { // BTN_DOWN
rtc.setTime(rtc.getSecond(), rtc.getMinute(), (rtc.getHour() + 14) % 24, rtc.getDay(), rtc.getMonth(), rtc.getYear());
}
} else if (state == METRO_EDIT) {
if (btn == BTN_UP) bpm = min(220, bpm + 10);
else bpm = max(40, bpm - 10);
timerAlarmWrite(timer, 60000000ULL / bpm, true); // Update timer for new BPM
}
}
void handleLongPress(byte btn) {
lastActivityTime = millis();
if (state == SHOW_TIME) {
if (btn == BTN_UP) { state = TIME_PICK; editMode = 0; }
else { state = METRO_SHOW; metroRunning = false; timerAlarmDisable(timer); digitalWrite(LED_PIN, LOW); ledPulse = false; }
} else if (state == TIME_PICK || state == TIME_EDIT) {
if (btn == BTN_UP) gotoShowTime();
} else if (state == METRO_SHOW || state == METRO_EDIT) {
if (btn == BTN_DOWN) gotoShowTime();
}
}
void handleBothOK() {
lastActivityTime = millis();
if (state == TIME_PICK) {
state = TIME_EDIT; // confirm selection → enter edit
} else if (state == TIME_EDIT) {
state = TIME_PICK; // save value → back to pick (for 2nd param)
// RTC time is already updated by handleShortPress/handleHoldIncrement
} else if (state == METRO_EDIT) {
state = METRO_SHOW; // save BPM → back to metronome main
timerAlarmWrite(timer, 60000000ULL / bpm, true); // Update timer for new BPM
}
}
// ─────────────────────────────────────────────────────────────
// SCREEN RENDER
// ─────────────────────────────────────────────────────────────
void renderScreen() {
if (state == SLEEP) return;
u8g2.clearBuffer();
// Get current time from RTC
int current_h = rtc.getHour();
int current_m = rtc.getMinute();
int current_s = rtc.getSecond();
if (state == SHOW_TIME) {
// 12-hour secondary time string (always top-left, horizontal)
char secTime[13];
int h12 = current_h % 12;
if (h12 == 0) h12 = 12;
const char *ampm = (current_h >= 12) ? "PM" : "AM";
sprintf(secTime, "%02d:%02d:%02d %s", h12, current_m, current_s, ampm);
u8g2.setFont(u8g2_font_6x10_tr);
u8g2.drawStr(0, 10, secTime); // top-left corner, small font
// 24-hour main time string
char mainTime[6];
sprintf(mainTime, "%02d:%02d", current_h, current_m);
#if TILTED_CLOCK
drawRotatedText(14, 58, mainTime, 30.0f, u8g2_font_profont29_tr);
#else
u8g2.setFont(u8g2_font_profont29_tr);
int tw = u8g2.getStrWidth(mainTime);
u8g2.drawStr((128 - tw) / 2, 48, mainTime);
#endif
}
else if (state == TIME_PICK) {
u8g2.setFont(u8g2_font_6x10_tr); u8g2.drawStr(4, 10, "SET TIME");
u8g2.drawStr(92, 10, editMode == 0 ? "HOUR" : "MIN");
char hh[3], mm[3]; sprintf(hh, "%02d", current_h); sprintf(mm, "%02d", current_m);
u8g2.setFont(u8g2_font_profont22_tr);
if (editMode == 0) { drawInverseTextBox(22, 42, hh, u8g2_font_profont22_tr); u8g2.drawStr(56, 42, ":"); u8g2.drawStr(68, 42, mm); }
else { u8g2.drawStr(22, 42, hh); u8g2.drawStr(56, 42, ":"); drawInverseTextBox(68, 42, mm, u8g2_font_profont22_tr); }
u8g2.setFont(u8g2_font_5x8_tr); u8g2.drawStr(4, 62, "P 1 H 10 BOTH OK");
}
else if (state == TIME_EDIT) {
u8g2.setFont(u8g2_font_6x10_tr); u8g2.drawStr(4, 10, "CHANGES...");
u8g2.drawStr(92, 10, editMode == 0 ? "HOUR" : "MIN");
char val[3]; sprintf(val, "%02d", editMode == 0 ? current_h : current_m);
u8g2.setFont(u8g2_font_profont29_tr); int tw = u8g2.getStrWidth(val);
drawInverseTextBox(64-tw/2-3, 46, val, u8g2_font_profont29_tr);
u8g2.setFont(u8g2_font_5x8_tr); u8g2.drawStr(4, 62, "P 1 H 10 BOTH OK");
}
else if (state == METRO_SHOW) {
u8g2.setFont(u8g2_font_6x10_tr); u8g2.drawStr(4, 10, "METRONOME");
char buf[12]; sprintf(buf, "%d BPM", bpm);
u8g2.setFont(u8g2_font_profont29_tr); int tw = u8g2.getStrWidth(buf); u8g2.drawStr(64-tw/2, 44, buf);
u8g2.setFont(u8g2_font_6x10_tr); u8g2.drawStr(50, 62, metroRunning ? "STOP" : "START?");
}
else if (state == METRO_EDIT) {
u8g2.setFont(u8g2_font_6x10_tr); u8g2.drawStr(4, 10, "CHANGES...");
char buf[8]; sprintf(buf, "%d", bpm);
u8g2.setFont(u8g2_font_profont29_tr); int tw = u8g2.getStrWidth(buf); u8g2.drawStr(64-tw/2, 44, buf);
u8g2.setFont(u8g2_font_5x8_tr); u8g2.drawStr(4, 62, "P 1 H 10 BOTH OK");
}
u8g2.sendBuffer();
}
// ─────────────────────────────────────────────────────────────
// SETUP
// ─────────────────────────────────────────────────────────────
void setup() {
pinMode(BTN_UP, INPUT_PULLUP); pinMode(BTN_DOWN, INPUT_PULLUP); pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
u8g2.begin(); u8g2.setFontMode(1);
// Initialize RTC (set a default time if not already set)
rtc.setTime(0, 0, 12, 1, 1, 2023); // 12:00:00 on Jan 1, 2023 (sec, min, hour, day, month, year)
// Initialize Hardware Timer for Metronome
timer = timerBegin(0, 80, true); // timer 0, 80MHz clock, count up
timerAttachInterrupt(timer, &onTimer, true); // attach callback
timerAlarmWrite(timer, 60000000ULL / bpm, true); // 60,000,000 us / BPM = interval for 1 beat
// timerAlarmEnable(timer); // Enable only when metronome is running
lastActivityTime = millis();
}
// ─────────────────────────────────────────────────────────────
// MAIN LOOP
// ─────────────────────────────────────────────────────────────
void loop() {
unsigned long now = millis();
// Handle metronome beat from ISR
if (metroBeat) {
metroBeat = false;
if (metroRunning) {
digitalWrite(LED_PIN, HIGH);
ledPulse = true;
ledOffAt = now + LED_PULSE_MS;
// Add sound click here if needed
}
}
if (ledPulse && now >= ledOffAt) {
digitalWrite(LED_PIN, LOW);
ledPulse = false;
}
// Update button states
updateButtonState(upBtn);
updateButtonState(downBtn);
bool currentUpPressed = upBtn.isPressed;
bool currentDownPressed = downBtn.isPressed;
// ── SLEEP / WAKE ──────────────────────────────────────────
if (state == SLEEP) {
if (currentUpPressed || currentDownPressed) {
if (metroRunning) state = METRO_SHOW; else state = SHOW_TIME;
lastActivityTime = now;
// Reset button states to prevent immediate action after wake
upBtn.pressStartTime = now;
upBtn.longPressFired = false;
upBtn.holdIncrementFired = false;
upBtn.lastHoldIncrementTime = 0;
downBtn.pressStartTime = now;
downBtn.longPressFired = false;
downBtn.holdIncrementFired = false;
downBtn.lastHoldIncrementTime = 0;
}
renderScreen();
return;
}
// ── SCREEN TIMEOUT ────────────────────────────────────────
// Only sleep if not in METRO_SHOW (if metro is running) or any edit modes
if ((state == SHOW_TIME || (state == METRO_SHOW && !metroRunning)) && (now - lastActivityTime > SLEEP_TIMEOUT_MS)) {
goSleep();
return;
}
// ── BUTTON LOGIC ──────────────────────────────────────────
// Check for BOTH OK (long press on both buttons)
// This needs to be checked before single button presses to prioritize combo
if (currentUpPressed && currentDownPressed) {
// If both buttons are pressed, record start time if not already recorded
if (upBtn.pressStartTime == 0) upBtn.pressStartTime = now;
if (downBtn.pressStartTime == 0) downBtn.pressStartTime = now;
// Check for asynchronous press within window
unsigned long firstPressTime = min(upBtn.pressStartTime, downBtn.pressStartTime);
unsigned long lastPressTime = max(upBtn.pressStartTime, downBtn.pressStartTime);
if (lastPressTime - firstPressTime <= BOTH_PRESS_WINDOW) {
if (now - firstPressTime >= BOTH_OK_HOLD_MS && !upBtn.longPressFired) {
handleBothOK();
upBtn.longPressFired = true; // Mark as fired to prevent re-trigger
downBtn.longPressFired = true;
upBtn.holdIncrementFired = true;
downBtn.holdIncrementFired = true;
}
}
} else { // If one or both buttons are released, reset combo state flags
// Only reset if not currently in a combo state that just fired
if (!currentUpPressed) {
upBtn.pressStartTime = 0;
upBtn.longPressFired = false;
upBtn.holdIncrementFired = false;
upBtn.lastHoldIncrementTime = 0;
}
if (!currentDownPressed) {
downBtn.pressStartTime = 0;
downBtn.longPressFired = false;
downBtn.holdIncrementFired = false;
downBtn.lastHoldIncrementTime = 0;
}
}
// Single button logic
// UP button
if (currentUpPressed) {
if (upBtn.pressStartTime == 0) upBtn.pressStartTime = now;
unsigned long duration = now - upBtn.pressStartTime;
if (duration >= LONG_PRESS_MS && !upBtn.longPressFired) {
handleLongPress(BTN_UP);
upBtn.longPressFired = true;
upBtn.holdIncrementFired = true; // Prevent short press and hold increment after long press
} else if (duration >= HOLD_MS && (state == TIME_EDIT || state == METRO_EDIT) && !upBtn.holdIncrementFired) {
if (now - upBtn.lastHoldIncrementTime >= HOLD_INCREMENT_INTERVAL_MS || upBtn.lastHoldIncrementTime == 0) {
handleHoldIncrement(BTN_UP);
upBtn.lastHoldIncrementTime = now;
upBtn.holdIncrementFired = true;
}
}
} else if (upBtn.lastIsPressed) { // UP button released
if (!upBtn.longPressFired && !upBtn.holdIncrementFired) { // Only trigger short press if no long/hold fired
handleShortPress(BTN_UP);
}
upBtn.pressStartTime = 0;
upBtn.longPressFired = false;
upBtn.holdIncrementFired = false;
upBtn.lastHoldIncrementTime = 0;
}
// DOWN button
if (currentDownPressed) {
if (downBtn.pressStartTime == 0) downBtn.pressStartTime = now;
unsigned long duration = now - downBtn.pressStartTime;
if (duration >= LONG_PRESS_MS && !downBtn.longPressFired) {
handleLongPress(BTN_DOWN);
downBtn.longPressFired = true;
downBtn.holdIncrementFired = true;
} else if (duration >= HOLD_MS && (state == TIME_EDIT || state == METRO_EDIT) && !downBtn.holdIncrementFired) {
if (now - downBtn.lastHoldIncrementTime >= HOLD_INCREMENT_INTERVAL_MS || downBtn.lastHoldIncrementTime == 0) {
handleHoldIncrement(BTN_DOWN);
downBtn.lastHoldIncrementTime = now;
downBtn.holdIncrementFired = true;
}
}
} else if (downBtn.lastIsPressed) { // DOWN button released
if (!downBtn.longPressFired && !downBtn.holdIncrementFired) {
handleShortPress(BTN_DOWN);
}
downBtn.pressStartTime = 0;
downBtn.longPressFired = false;
downBtn.holdIncrementFired = false;
downBtn.lastHoldIncrementTime = 0;
}
renderScreen();
}