Продвинутый курс Arduino · Большие проекты 2
Урок 7. Проект: часы-будильник
Устройство на тумбочку у кровати: экран показывает время с секундами, кнопки настраивают часы и будильник, зуммер поднимает по утрам — а настройки хранятся в вечной памяти.
Что понадобится
- Контроллер Uno и кабель USB
- Макетная плата
- Экран LCD1602 и потенциометр 10 кОм (контраст)
- 3 кнопки
- Зуммер
- Провода
Все детали есть в обучающем наборе, по которому идёт курс.
Начинаем финальный блок — «Большие проекты 2». Первое устройство просится на тумбочку у кровати: настоящие часы-будильник. Экран показывает время с секундами, три кнопки настраивают и часы, и будильник, зуммер поднимает по утрам, а вечная память из первого урока хранит настройки при выключении. Новых деталей нет, новой будет инженерия: как из «миллисекунд с момента включения» собрать честные часы.
Собираем схему
Схема — в точности из урока про игру «Повтори за мной»: экран с потенциометром контраста, три кнопки на выводах 7, 8 и 10, зуммер на выводе 6. Если она сохранилась у вас собранной — вы уже готовы.
Шаг 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. Ставим время кнопками
// Продвинутый курс, урок 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 с половиной суток.
Будильник сработает, если плата была выключена в момент звонка?
Нет: пока нет питания, плата не живёт и сравнивать время некому. Пропущенный звонок не «догоняется» и после включения — сравнение строгое, ровно в назначенную секунду. Это честная плата за простоту; зато включённый будильник никогда не проспит, пока приставка в розетке.
