Продвинутый курс Arduino · Большие проекты 2

Урок 7. Проект: часы-будильник

Устройство на тумбочку у кровати: экран показывает время с секундами, кнопки настраивают часы и будильник, зуммер поднимает по утрам — а настройки хранятся в вечной памяти.

Урок 7 из 8⏱ примерно 50 минутОбновлено: 14 августа 2026

Что понадобится

  • Контроллер Uno и кабель USB
  • Макетная плата
  • Экран LCD1602 и потенциометр 10 кОм (контраст)
  • 3 кнопки
  • Зуммер
  • Провода

Все детали есть в обучающем наборе, по которому идёт курс.

Начинаем финальный блок — «Большие проекты 2». Первое устройство просится на тумбочку у кровати: настоящие часы-будильник. Экран показывает время с секундами, три кнопки настраивают и часы, и будильник, зуммер поднимает по утрам, а вечная память из первого урока хранит настройки при выключении. Новых деталей нет, новой будет инженерия: как из «миллисекунд с момента включения» собрать честные часы.

Собираем схему

Схема — в точности из урока про игру «Повтори за мной»: экран с потенциометром контраста, три кнопки на выводах 7, 8 и 10, зуммер на выводе 6. Если она сохранилась у вас собранной — вы уже готовы.

+abcdefghi+Uroven5Smotri...12345678910111213141516кнопка 1кнопка 2кнопка 3151520911132426283012341Экран, потенциометр контраста и три кнопки — в точности как в уроке 29, сейф не разбираем.2Зуммер висит над шинами: ножки в ряд a, столбики 10 и 13, знаком «+» вправо. Отверстия шин под его корпусом не используем.3Сигнал зуммера: из столбика 13 вниз на дорожку, затем направо, наверх и на вывод 6.4Минус зуммера: из столбика 10 по дорожке налево и в самое первое отверстие шины «−».Сервопривод из урока 29 больше не нужен: выньте три его провода — вывод 9 освободился.Провод соединён только там, где на нём светлая шляпка; шины он пересекает между отверстиями.
Всё как в тридцатом уроке базового курса: экран, три кнопки верхом на канавке, зуммер.

Шаг 1. Модель времени

У платы нет часов с циферблатом — есть только millis(), счётчик миллисекунд с включения. Как из него сделать «сейчас 09:30:45»? Приём такой: заведём переменную daySecсколько секунд прошло с полуночи. Раз в секунду прибавляем единицу; дошли до 86 400 — наступила новая полночь.

// Продвинутый курс, урок 7. Шаг 1: часы тикают.

#include <LiquidCrystal.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

long daySec = 9L * 3600 + 30 * 60;   // секунд с полуночи: старт в 09:30:00
unsigned long lastTick = 0;

void setup() {
  lcd.begin(16, 2);
  drawClock();
}

void loop() {
  if (millis() - lastTick >= 1000) {
    // Не «lastTick = millis()», а «+ 1000»: если мы проверили часы на пару
    // миллисекунд позже, остаток не пропадает — секунды не разбегаются.
    lastTick = lastTick + 1000;

    daySec = daySec + 1;
    if (daySec >= 86400L) {
      daySec = 0;               // полночь: 86 400 секунд — и сутки заново
    }
    drawClock();
  }
}

void drawClock() {
  int h = daySec / 3600;              // целые часы
  int m = (daySec % 3600) / 60;       // остаток без часов → целые минуты
  int s = daySec % 60;                // остаток от минуты → секунды

  lcd.setCursor(4, 0);
  printTwo(h);
  lcd.print(":");
  printTwo(m);
  lcd.print(":");
  printTwo(s);
}

// Ведущий ноль: 7 минут — это «07», иначе время скачет по ширине.
void printTwo(int value) {
  if (value < 10) {
    lcd.print("0");
  }
  lcd.print(value);
}

Здесь два места, ради которых стоит замедлиться. Первое — строка lastTick = lastTick + 1000. Заманчиво написать привычное lastTick = millis(), но тогда каждая проверка, опоздавшая на пару миллисекунд, воровала бы эти миллисекунды у следующей секунды — и часы понемногу отставали бы. А так опоздание не копится: следующий тик наступит ровно через 1000 миллисекунд от предыдущего тика, а не от момента проверки.

Второе — превращение секунд в часы-минуты-секунды. Тут работает пара операций: / — целочисленное деление («сколько целых часов влезло») и %остаток от деления («что осталось после часов»). 34 245 секунд ÷ 3600 = 9 часов; остаток 1845 ÷ 60 = 30 минут; остаток 45 — секунды. Получается 09:30:45. Функция printTwo дорисовывает ведущие нули — без них «09:07» превращалось бы в прыгающее «9:7».

Шаг 2. Ставим время кнопками

09:30:45Bud07:00[vkl]09::45StavimMINUTY07:00:02PODYOM!!!кнопка 1пришло времяОбычный ходвремя + строка будильникаУстановканастраиваемое поле мигает, 2 и 3 крутятЗвонлюбая кнопка — подъёмКнопка 1 ведёт по кругу: ход → часы → минуты → будильник-часы → будильник-минуты →вкл/выкл → снова ход. Часы продолжают тикать в любом режиме.
Кнопка 1 листает режимы по кругу, кнопки 2 и 3 крутят настраиваемое поле. Оно мигает — как в настоящих часах.
// Продвинутый курс, урок 7. Шаг 2: ставим время кнопками.

#include <LiquidCrystal.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

const int BTN_MODE = 7;     // кнопка 1: какой режим
const int BTN_PLUS = 8;     // кнопка 2: прибавить
const int BTN_MINUS = 10;   // кнопка 3: убавить

const int MODE_CLOCK = 0;   // обычный ход
const int MODE_SET_H = 1;   // установка часов
const int MODE_SET_M = 2;   // установка минут

int mode = MODE_CLOCK;
long daySec = 9L * 3600;
unsigned long lastTick = 0;
bool wasPressed[3] = {false, false, false};

void setup() {
  lcd.begin(16, 2);
  pinMode(BTN_MODE, INPUT_PULLUP);
  pinMode(BTN_PLUS, INPUT_PULLUP);
  pinMode(BTN_MINUS, INPUT_PULLUP);
}

// Одна функция на все кнопки: ловим момент нажатия, как в пульте из урока 13.
bool clicked(int pin, int index) {
  bool now = (digitalRead(pin) == LOW);
  bool click = now && !wasPressed[index];
  wasPressed[index] = now;
  if (click) {
    delay(30);   // антидребезг
  }
  return click;
}

void loop() {
  // Часы тикают в ЛЮБОМ режиме — установка их не останавливает.
  if (millis() - lastTick >= 1000) {
    lastTick = lastTick + 1000;
    daySec = daySec + 1;
    if (daySec >= 86400L) {
      daySec = 0;
    }
  }

  if (clicked(BTN_MODE, 0)) {
    mode = mode + 1;
    if (mode > MODE_SET_M) {
      mode = MODE_CLOCK;
    }
    lcd.clear();
  }

  if (mode == MODE_SET_H) {
    if (clicked(BTN_PLUS, 1)) {
      daySec = daySec + 3600;
      if (daySec >= 86400L) daySec = daySec - 86400L;
    }
    if (clicked(BTN_MINUS, 2)) {
      daySec = daySec - 3600;
      if (daySec < 0) daySec = daySec + 86400L;
    }
  }
  if (mode == MODE_SET_M) {
    if (clicked(BTN_PLUS, 1)) {
      daySec = daySec + 60;
      if (daySec >= 86400L) daySec = daySec - 86400L;
    }
    if (clicked(BTN_MINUS, 2)) {
      daySec = daySec - 60;
      if (daySec < 0) daySec = daySec + 86400L;
    }
  }

  drawClock();
}

void drawClock() {
  int h = daySec / 3600;
  int m = (daySec % 3600) / 60;
  int s = daySec % 60;

  // Настраиваемое поле мигает: два раза в секунду прячем его цифры.
  bool hidden = (millis() / 300) % 2 == 1;

  lcd.setCursor(4, 0);
  if (mode == MODE_SET_H && hidden) {
    lcd.print("  ");
  } else {
    printTwo(h);
  }
  lcd.print(":");
  if (mode == MODE_SET_M && hidden) {
    lcd.print("  ");
  } else {
    printTwo(m);
  }
  lcd.print(":");
  printTwo(s);

  lcd.setCursor(0, 1);
  if (mode == MODE_CLOCK) {
    lcd.print("                ");
  } else if (mode == MODE_SET_H) {
    lcd.print("Stavim CHASY    ");
  } else {
    lcd.print("Stavim MINUTY   ");
  }
}

void printTwo(int value) {
  if (value < 10) {
    lcd.print("0");
  }
  lcd.print(value);
}

Знакомая по сейфу машина состояний: режим хранится в переменной, кнопка 1 перещёлкивает его по кругу, а смысл кнопок 2 и 3 зависит от режима. Мигание настраиваемого поля сделано без единого delay: выражение (millis() / 300) % 2 даёт то 0, то 1 каждые 300 миллисекунд — и в «скрытую» фазу вместо цифр печатаются пробелы. Часы при этом продолжают тикать: время не останавливается, пока вы его настраиваете.

Шаг 3. Будильник подаёт голос

// Продвинутый курс, урок 7. Шаг 3: будильник подаёт голос.

#include <LiquidCrystal.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

const int BUTTONS[3] = {7, 8, 10};
const int BUZZER = 6;

long daySec = 6L * 3600 + 59 * 60 + 50;   // 06:59:50 — до звонка 10 секунд
long alarmSec = 7L * 3600;                // будильник на 07:00
unsigned long lastTick = 0;
bool ringing = false;
unsigned long lastBeep = 0;

void setup() {
  lcd.begin(16, 2);
  for (int i = 0; i < 3; i++) {
    pinMode(BUTTONS[i], INPUT_PULLUP);
  }
}

void loop() {
  if (millis() - lastTick >= 1000) {
    lastTick = lastTick + 1000;
    daySec = daySec + 1;
    if (daySec >= 86400L) {
      daySec = 0;
    }

    // Сравниваем ровно раз в секунду — момент не проскочит.
    if (daySec == alarmSec) {
      ringing = true;
    }
    drawClock();
  }

  if (ringing) {
    // Звон без delay: пик-пик по расписанию millis.
    if (millis() - lastBeep >= 600) {
      lastBeep = millis();
      tone(BUZZER, 880, 250);
    }
    // Любая кнопка — подъём!
    for (int i = 0; i < 3; i++) {
      if (digitalRead(BUTTONS[i]) == LOW) {
        ringing = false;
        noTone(BUZZER);
        lcd.clear();
      }
    }
  }
}

void drawClock() {
  lcd.setCursor(4, 0);
  printTwo(daySec / 3600);
  lcd.print(":");
  printTwo((daySec % 3600) / 60);
  lcd.print(":");
  printTwo(daySec % 60);

  lcd.setCursor(0, 1);
  if (ringing) {
    lcd.print("PODYOM!!!       ");
  } else {
    lcd.print("Budilnik: 07:00 ");
  }
}

void printTwo(int value) {
  if (value < 10) {
    lcd.print("0");
  }
  lcd.print(value);
}

Сердце будильника — одно сравнение: daySec == alarmSec, проверяемое ровно раз в секунду, в момент тика. Звон устроен вежливо: «пик-пик» по расписанию millis(), чтобы плата всё это время слышала кнопки. Любая из трёх — подъём. Для проверки время старта в скетче нарочно поставлено за 10 секунд до звонка — загрузите и досчитайте до десяти.

Шаг 4. Будильник, который ничего не забывает

// Продвинутый курс, урок 7. Шаг 4: будильник, который ничего не забывает.

#include <LiquidCrystal.h>
#include <EEPROM.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

const int BTN_MODE = 7;
const int BTN_PLUS = 8;
const int BTN_MINUS = 10;
const int BUZZER = 6;

// Карта вечной памяти этого проекта.
const int ADDR_ALARM_H = 50;    // час будильника
const int ADDR_ALARM_M = 51;    // минута будильника
const int ADDR_ALARM_ON = 52;   // включён ли: 1 или 0

const int MODE_CLOCK = 0;
const int MODE_SET_H = 1;
const int MODE_SET_M = 2;
const int MODE_ALARM_H = 3;
const int MODE_ALARM_M = 4;
const int MODE_ALARM_ON = 5;

int mode = MODE_CLOCK;
long daySec = 9L * 3600;
int alarmH = 7;
int alarmM = 0;
bool alarmOn = true;
bool ringing = false;

unsigned long lastTick = 0;
unsigned long lastBeep = 0;
bool wasPressed[3] = {false, false, false};

void setup() {
  lcd.begin(16, 2);
  pinMode(BTN_MODE, INPUT_PULLUP);
  pinMode(BTN_PLUS, INPUT_PULLUP);
  pinMode(BTN_MINUS, INPUT_PULLUP);

  loadAlarm();   // будильник достаётся из вечной памяти
}

void loadAlarm() {
  byte h = EEPROM.read(ADDR_ALARM_H);
  byte m = EEPROM.read(ADDR_ALARM_M);
  byte on = EEPROM.read(ADDR_ALARM_ON);

  // Первый запуск: в ящиках 255 — ставим заводской будильник 07:00.
  if (h > 23 || m > 59 || on > 1) {
    h = 7;
    m = 0;
    on = 1;
    saveAlarm(h, m, on);
  }
  alarmH = h;
  alarmM = m;
  alarmOn = (on == 1);
}

void saveAlarm(byte h, byte m, byte on) {
  EEPROM.update(ADDR_ALARM_H, h);
  EEPROM.update(ADDR_ALARM_M, m);
  EEPROM.update(ADDR_ALARM_ON, on);
}

bool clicked(int pin, int index) {
  bool now = (digitalRead(pin) == LOW);
  bool click = now && !wasPressed[index];
  wasPressed[index] = now;
  if (click) {
    delay(30);
  }
  return click;
}

void loop() {
  if (millis() - lastTick >= 1000) {
    lastTick = lastTick + 1000;
    daySec = daySec + 1;
    if (daySec >= 86400L) {
      daySec = 0;
    }
    if (alarmOn && daySec == (long)alarmH * 3600 + (long)alarmM * 60) {
      ringing = true;
    }
  }

  if (ringing) {
    if (millis() - lastBeep >= 600) {
      lastBeep = millis();
      tone(BUZZER, 880, 250);
    }
    if (digitalRead(BTN_MODE) == LOW || digitalRead(BTN_PLUS) == LOW ||
        digitalRead(BTN_MINUS) == LOW) {
      ringing = false;
      noTone(BUZZER);
      lcd.clear();
      delay(300);   // чтобы это же нажатие не начало листать режимы
    }
    drawScreen();
    return;
  }

  if (clicked(BTN_MODE, 0)) {
    mode = mode + 1;
    if (mode > MODE_ALARM_ON) {
      mode = MODE_CLOCK;
    }
    lcd.clear();
  }

  int plus = 0;
  if (clicked(BTN_PLUS, 1)) plus = 1;
  if (clicked(BTN_MINUS, 2)) plus = -1;

  if (plus != 0) {
    if (mode == MODE_SET_H) {
      daySec = daySec + plus * 3600L;
    }
    if (mode == MODE_SET_M) {
      daySec = daySec + plus * 60L;
    }
    if (daySec < 0) daySec = daySec + 86400L;
    if (daySec >= 86400L) daySec = daySec - 86400L;

    if (mode == MODE_ALARM_H) {
      alarmH = alarmH + plus;
      if (alarmH > 23) alarmH = 0;
      if (alarmH < 0) alarmH = 23;
      saveAlarm(alarmH, alarmM, alarmOn);
    }
    if (mode == MODE_ALARM_M) {
      alarmM = alarmM + plus;
      if (alarmM > 59) alarmM = 0;
      if (alarmM < 0) alarmM = 59;
      saveAlarm(alarmH, alarmM, alarmOn);
    }
    if (mode == MODE_ALARM_ON) {
      alarmOn = !alarmOn;
      saveAlarm(alarmH, alarmM, alarmOn);
    }
  }

  drawScreen();
}

void drawScreen() {
  bool hidden = (millis() / 300) % 2 == 1;

  lcd.setCursor(4, 0);
  if (mode == MODE_SET_H && hidden) {
    lcd.print("  ");
  } else {
    printTwo(daySec / 3600);
  }
  lcd.print(":");
  if (mode == MODE_SET_M && hidden) {
    lcd.print("  ");
  } else {
    printTwo((daySec % 3600) / 60);
  }
  lcd.print(":");
  printTwo(daySec % 60);

  lcd.setCursor(0, 1);
  if (ringing) {
    lcd.print("PODYOM!!!       ");
    return;
  }
  lcd.print("Bud ");
  if (mode == MODE_ALARM_H && hidden) {
    lcd.print("  ");
  } else {
    printTwo(alarmH);
  }
  lcd.print(":");
  if (mode == MODE_ALARM_M && hidden) {
    lcd.print("  ");
  } else {
    printTwo(alarmM);
  }
  if (mode == MODE_ALARM_ON && hidden) {
    lcd.print("      ");
  } else if (alarmOn) {
    lcd.print(" [vkl] ");
  } else {
    lcd.print(" [vykl]");
  }
}

void printTwo(int value) {
  if (value < 10) {
    lcd.print("0");
  }
  lcd.print(value);
}

Финальная версия соединяет всё: шесть режимов (ход, часы, минуты, будильник-часы, будильник-минуты, вкл/выкл), мигающие поля и — главное — вечную память. Час, минута и выключатель будильника живут в ящиках 50–52; функция loadAlarm() при старте проверяет их на разумность — знакомый по сейфу приём с заводскими настройками, — а каждая правка тут же сохраняется через update.

Главная проверка: поставьте будильник на пару минут вперёд, выдерните USB, вставьте обратно. Время придётся поставить заново (часы без питания не шли — это честно), а вот будильник помнит и время звонка, и что он включён. Настройки пережили темноту.

Насколько точны такие часы? Кварц платы даёт ошибку около пары секунд в сутки — для будильника терпимо, но за неделю набежит заметно. У настоящих часов есть отдельная микросхема-«часовщик» с батарейкой, которая и время не теряет, и точнее в десятки раз. В нашем наборе её нет — зато теперь вы знаете, за что такие микросхемы любят.

Частые проблемы

  • Часы спешат или отстают на минуты за час. Проверьте строку тика: там должно быть lastTick = lastTick + 1000, а сравнение — через millis() - lastTick >= 1000.
  • При установке цифры «скачут» через одну. Дребезг: убедитесь, что нажатия идут через функцию clicked с её delay(30).
  • Будильник звонит каждый запуск в 07:00, хотя вы ставили другое. Похоже, загрузка из памяти не отработала: проверьте адреса — ящики 50–52 не должны пересекаться с опытами прошлых уроков (там мы занимали 0–41 и 60-е).
  • После звона сам открывается режим установки. Кнопка глушения ещё нажата, когда звон уже выключен. В финальном скетче после глушения стоит delay(300) — верните его.

Задания для тренировки

  • РазминкаДобавьте будильнику собственную мелодию вместо «пик-пик» — три ноты из игры «Повтори за мной» подойдут отлично. Только не забудьте: звон не должен глушить кнопки.
  • ПосложнееСделайте ночной режим: с 22:00 до 7:00 показывать время без секунд. Условие «внутри интервала, переходящего через полночь» — маленькая, но красивая головоломка.
  • Со звёздочкой ⭐Кнопка «ещё пять минут»! Во время звона кнопка 3 не выключает будильник, а откладывает его. Разбор ниже.
Показать разбор «ещё пять минут»

Вся хитрость — не трогать сохранённое время будильника, а завести одноразовый перенос: alarmSec = daySec + 300. Завтра будильник снова зазвонит в своё законное время из вечной памяти.

// Продвинутый курс, урок 7. Разбор задания: «ещё пять минут».
// Отличие от шага 4 — поведение кнопок во время звона: кнопка 3 не глушит
// будильник, а откладывает его. Показан только изменившийся кусок логики:
// вставьте его в скетч шага 4 вместо блока «if (ringing) { ... }».

#include <LiquidCrystal.h>

LiquidCrystal lcd(12, 11, 5, 4, 3, 2);

const int BTN_MODE = 7;
const int BTN_PLUS = 8;
const int BTN_MINUS = 10;
const int BUZZER = 6;

long daySec = 6L * 3600 + 59 * 60 + 55;
long alarmSec = 7L * 3600;      // на сегодня; отложенный звон меняет только её
bool ringing = false;
unsigned long lastTick = 0;
unsigned long lastBeep = 0;

void setup() {
  lcd.begin(16, 2);
  pinMode(BTN_MODE, INPUT_PULLUP);
  pinMode(BTN_PLUS, INPUT_PULLUP);
  pinMode(BTN_MINUS, INPUT_PULLUP);
}

void loop() {
  if (millis() - lastTick >= 1000) {
    lastTick = lastTick + 1000;
    daySec = daySec + 1;
    if (daySec >= 86400L) {
      daySec = 0;
    }
    if (daySec == alarmSec) {
      ringing = true;
    }
    drawScreen();
  }

  if (ringing) {
    if (millis() - lastBeep >= 600) {
      lastBeep = millis();
      tone(BUZZER, 880, 250);
    }

    // Кнопка 3 — «ещё пять минут»: будильник замолкает и взводится заново.
    if (digitalRead(BTN_MINUS) == LOW) {
      ringing = false;
      noTone(BUZZER);
      alarmSec = daySec + 5L * 60;
      if (alarmSec >= 86400L) {
        alarmSec = alarmSec - 86400L;   // «ещё пять минут» около полуночи
      }
      lcd.clear();
      delay(300);
    }

    // Кнопки 1 и 2 — честный подъём до завтрашнего утра.
    if (digitalRead(BTN_MODE) == LOW || digitalRead(BTN_PLUS) == LOW) {
      ringing = false;
      noTone(BUZZER);
      lcd.clear();
      delay(300);
    }
  }
}

void drawScreen() {
  lcd.setCursor(4, 0);
  printTwo(daySec / 3600);
  lcd.print(":");
  printTwo((daySec % 3600) / 60);
  lcd.print(":");
  printTwo(daySec % 60);

  lcd.setCursor(0, 1);
  if (ringing) {
    lcd.print("PODYOM! 3=+5min ");
  } else {
    lcd.print("Zvon: ");
    printTwo(alarmSec / 3600);
    lcd.print(":");
    printTwo((alarmSec % 3600) / 60);
    lcd.print("     ");
  }
}

void printTwo(int value) {
  if (value < 10) {
    lcd.print("0");
  }
  lcd.print(value);
}

Обратите внимание на проверку у полуночи: если отложить звон в 23:58, сумма перевалит за 86 400 — и без вычитания суток будильник замолчал бы навсегда. Пограничные случаи — любимое место ошибок у настоящих инженеров: теперь вы ловите их сами.

Что вы теперь умеете

  • строить модель времени: секунды суток, тик без накопления ошибки;
  • пользоваться целочисленным делением и остатком %;
  • печатать аккуратные числа с ведущими нулями;
  • делать многорежимную настройку на трёх кнопках с мигающим полем;
  • хранить настройки устройства в вечной памяти — как взрослая техника.

Остался один урок. Та же схема, но совсем другое настроение: соберём игровую приставку — с меню, тремя играми и таблицей рекордов, которую не сотрёт даже выключение.

Частые вопросы

Почему после выключения время приходится ставить заново?

Часы идут на счётчике millis, а он живёт только при питании. Хранить текущее время в EEPROM нельзя: его пришлось бы записывать каждую секунду, и ресурс памяти кончился бы за сутки. Настоящие часы решают это отдельной микросхемой с батарейкой; наши честно хранят лишь то, что меняется редко, — будильник.

Можно ли сделать формат 12 часов с AM/PM?

Да, и это неплохое упражнение: модель времени не меняется, меняется только показ. Час для экрана — это остаток от деления на 12 (ноль превращаем в 12), а надпись AM или PM выбирается сравнением daySec с половиной суток.

Будильник сработает, если плата была выключена в момент звонка?

Нет: пока нет питания, плата не живёт и сравнивать время некому. Пропущенный звонок не «догоняется» и после включения — сравнение строгое, ровно в назначенную секунду. Это честная плата за простоту; зато включённый будильник никогда не проспит, пока приставка в розетке.

← Все уроки курса