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


// ============================================================
//  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();
}