Часы на DS3231 у меня в мастерской звонили зуммером ровно в семь утра — и ровно в семь в субботу тоже, потому что аппаратный будильник чипа про календарь не спрашивает. Два регистра Alarm 1 и Alarm 2 из урока про будильники DS3231 умеют звенеть в назначенный день недели, но будни целиком, пять дней подряд, одним регистром не описать, а отсрочки snooze в чипе нет вовсе. Куда ни крути — нужен слой логики поверх часов. Такой слой я собираю софтверно: сверка DateTime в millis-цикле, столько целей, сколько влезет в память, будни и отсрочки — обычными сравнениями.
Здесь разберу именно эту машинерию: как ставить цель-момент и ловить её в неблокирующем цикле (сам принцип цикла без паузы — тема урока про millis вместо delay), почему сравнение делается через больше-или-равно, как описать «только по будням» и что честно умеет симулятор. Если нужен будильник на один момент с зуммером и флагом — проще взять готовый каркас из урока про часы с будильником; здесь же речь про расписание со звёздочками: будни, отсрочки, потолок откладываний.
Сравнение: операторы на плате, unixtime в симуляторе
В RTClib для DateTime определены операторы ==, !=, <, >, <=, >=, и на настоящей плате будильник сжимается в одну строку: if (now >= celi) — звонить. Момент цели строю из текущего снимка: DateTime(now.year(), now.month(), now.day(), ЧАС, МИНУТА, 0) — и каждый цикл сравниваю снимок с целью. Вся механика этих шести операторов, знаки разностей и ловушка беззнакового вычитания уже разобраны в уроке про сравнение DateTime — здесь беру только то, что работает на будильник.
В симуляторе на этом сайте операторы DateTime не эмулируются: проверка now >= celi молча уходит в ветку «нет», как будто сравнения не было. Сим-скетч ниже поэтому построен на разностях unixtime: у обоих моментов берутся секунды с 1970 года и вычитаются — это тот же механизм, на котором операторы устроены внутри библиотеки. Для будильника разность даже удобнее: она сразу отвечает и «пора ли», и «сколько осталось», и печатается в статус одной цифрой.
Про unixtime как единый источник — отдельная мысль, которую я в первом скетче упустил и переделал. Если в проекте и будильник, и лог, и расписание полива, считайте всё в секундах с 1970: одно сравнение длинных целых вместо шести полей, один перенос между ветками без перевода туда-обратно. DateTime умеет выдать unixtime и построиться обратно из него, и я держу в проекте одну функцию «сейчас в секундах»; переполнение снимается беззнаковым 32-разрядным счётом до 2106 года — для домашнего прибора горизонт достаточный.
Сравнивать буду через >=, а не ==, и это не вкусовщина. Цель 06:45:00 живёт ровно одну секунду; цикл с паузой delay(1000) проверяет время примерно раз в секунду с дрожью в десятки миллисекунд, и на границе может въехать в проверку уже в 06:45:01 — точное равенство промахивается молча, без единого сообщения. Нестрогое сравнение держит открытое окно с момента цели: пока секунды идут вперёд, условие остаётся истинным. Окно закрывается флагом «уже звонил», иначе звонок будет повторяться каждые полсекунды до конца дня — вторая по популярности жалоба после полного молчания.
#include <Wire.h>#include <RTClib.h> RTC_DS3231 rtc;const int ZUMMER = 8; // зуммер: tone() занимает Timer2, ШИМ-пины 3 и 11 трогать не надоconst int KNOPKA = 2; // кнопка snooze/стоп на GNDconst int LED = 12;const int CHAS = 6; // подъём в 6:45const int MINUTA = 45;const bool TOLKO_BUDNI = true;const int MAX_OTLOZHENIJ = 3; // три откладывания - и будильник сдался DateTime celi;bool zvonit = false;bool tixo = false; // сегодняшний звонок закрытint otlozheniya = 0;int denCeli = -1;unsigned long lastBeep = 0;bool beepOn = false; bool budni(DateTime t) { int d = t.dayOfTheWeek(); // RTClib: 0 - воскресенье, 6 - суббота return d >= 1 && d <= 5;} void novayaCely(DateTime now) { celi = DateTime(now.year(), now.month(), now.day(), CHAS, MINUTA, 0); while (TOLKO_BUDNI && !budni(celi)) { celi = celi + TimeSpan(1, 0, 0, 0); // сутки вперёд до ближайшего буднего дня } if (now >= celi) { // сегодня уже проспали - переносим на завтра celi = celi + TimeSpan(1, 0, 0, 0); while (TOLKO_BUDNI && !budni(celi)) { celi = celi + TimeSpan(1, 0, 0, 0); } } denCeli = now.day(); tixo = false; otlozheniya = 0;} void setup() { Serial.begin(9600); pinMode(ZUMMER, OUTPUT); pinMode(LED, OUTPUT); pinMode(KNOPKA, INPUT_PULLUP); if (rtc.begin() == false) { Serial.println("DS3231 ne najden: SDA A4, SCL A5"); while (1) {} } novayaCely(rtc.now()); Serial.print("Cely: den "); Serial.print(celi.day()); Serial.print(", "); Serial.print(celi.hour()); Serial.println(":45");} void loop() { DateTime now = rtc.now(); if (now.day() != denCeli) { // наступил новый день - новая цель novayaCely(now); } if (!tixo && !zvonit && now >= celi) { zvonit = true; // окно открыто, флаг не даёт повторить } if (zvonit && now >= celi + TimeSpan(0, 0, 1, 30)) { zvonit = false; // звонили полторы минуты - сами затихли tixo = true; noTone(ZUMMER); digitalWrite(LED, LOW); } if (zvonit && millis() - lastBeep >= 400) { // пульс: 400 мс звук, 400 мс пауза lastBeep = millis(); beepOn = !beepOn; if (beepOn) tone(ZUMMER, 2200); else noTone(ZUMMER); digitalWrite(LED, beepOn ? HIGH : LOW); } if (digitalRead(KNOPKA) == LOW) { if (zvonit) { zvonit = false; noTone(ZUMMER); digitalWrite(LED, LOW); otlozheniya++; if (otlozheniya <= MAX_OTLOZHENIJ) { celi = celi + TimeSpan(0, 0, 9, 0); // классические девять минут snooze } else { tixo = true; // потолок откладываний - тишина до завтра } } else { tixo = true; // вне звонка кнопка глушит будильник на день } while (digitalRead(KNOPKA) == LOW) { delay(10); } // ждём отпускания } delay(50);}По будням и девять минут: где прячутся грабли
Режим «только будни» опирается на dayOfTheWeek(), а тот вычисляется из даты — чип про недели не знает. Нумерация на плате: 0 — воскресенье, 1 — понедельник, 6 — суббота; будни описываются условием d >= 1 && d <= 5. В симуляторе движок нумерует по другой привычке — с понедельника, там будни это 0..4. Обе нумерации законны, но массив имён обязан идти в порядке нумерации метода, и лучший способ не вляпаться — калибровка по известной дате, разобранная в уроке про дни недели и месяцы: напечатали день для даты, которую помните, сверили с календарём — и спите спокойно.
Отсрочка — не выключение, а сдвиг цели: celi = celi + TimeSpan(0, 0, 9, 0), и через девять минут условие сработает снова. Почему девять, а не десять: расхожая версия уводит эту цифру к механике старых радиобудильников, где шаг шестерни задавал интервал от 9 до 10,1 минуты, и кратность ровно десяти минутам просто не сходилась, — так и застыло девятью минутами во всех часах мира. Практический вывод важнее истории: snooze без потолка превращает подъём в дремоту до обеда, поэтому у меня счётчик откладываний упирается в три, дальше кнопка глушит звонок до следующего дня. Счётчик сбрасывается при перестройке цели на новый день — иначе суббота унаследует отговорки пятницы.
У софтового слоя есть граница, о которой честно скажу сразу: он живёт ровно столько, сколько крутится ваш loop. Глубокий сон, зависший Wire при зашумлённой шине, зажатый delay в чужой библиотеке — и будильник промолчит, потому что время некому сверить. Для узлов, которые спят между звонками, территория аппаратных будильников и пина INT/SQW: регистр ловит момент сам, а прерывание будит плату — эта связка с двумя будильниками и соном разобрана в отдельном уроке. Для всегда включённого устройства в розетке софтовый слой проще и гибче — цели добавляются копипастой одной строки расписания.
Точность самой базы времени у DS3231 лучше, чем у кварца платы, на порядки: чип живёт с температурно-компенсированным кварцем, и даташит обещает ±2 ppm в диапазоне 0-40 °C — это около 0,2 секунды в сутки или пять секунд за месяц. Обычный кварц Arduino без компенсации уезжает на десятки-сотни ppm, и именно поэтому время стоит читать из внешних часов, а не собирать по millis. Одна оговорка: компенсатор делает замеры кристалла каждые 64 секунды, и сам датчик греется от чипа, поэтому rtc.getTemperature() покажет температуру корпуса, а не погоду в комнате.
Про будни добавлю приём, который экономит строки. Вместо массива флагов на семь дней будни — это диапазон: на плате d >= 1 && d <= 5, и оба края описываются одной функцией budni() из скетча. Смещение цели на сутки делается TimeSpan(1,0,0,0), а не пересборкой DateTime: переносятся и число месяца, и месяц, и високосный год — арифметикой занимается библиотека. Snooze по той же причине не переустанавливает будильник, а сдвигает готовую цель: регистры чипа не трогаются вовсе, и после третьего откладывания в настройках не остаётся мусора, который пришлось бы чистить в субботу.
Прогоните сценарий как он есть. A1 — цель через минуту; в статусе S видно текущее время, день недели по-русски и число откладываний. Когда окно откроется, зуммер запульсирует с периодом 800 мс, LED моргает в такт — на плате это те же 400 мс tone/noTone из скетча. Кнопка D2 в звонке откладывает цель на девять минут и печатает, во сколько следующий звонок; после третьего откладывания молчание станет окончательным до новой цели. Если сегодня выходной, режим W1 по умолчанию снимет цель с честной строкой в мониторе — W0 вернёт ежедневный звонок.
Пара границ, чтобы вы не искали в модели то, чего там нет. Точность срабатывания здесь — точность цикла: с delay(50) звонок придёт с задержкой до пятидесяти миллисекунд, на плате — столько же плюс дрожь цикла, и для подъёма в шесть сорок пять это не имеет значения. Проверка здравым смыслом перед установкой: цель на минуту вперёд, взгляд на монитор, звонок ровно в назначенную секунду — пять минут на столе экономят вечер в щитке. И не храните цель в переменной, переживающей перезагрузку: после моргания питания цель надо строить заново из снимка — новенькая цель на 6:45 на пустом месте безопаснее дежурного числа в EEPROM, о нём я писал в разборе счётчика включений, но там была статистика, а тут расписание, которому надо просыпаться.
| Слой будильника | Что умеет | Чем платит |
|---|---|---|
| Аппаратный: Alarm 1/2 в регистрах | звенит сам, даже когда скетч спит или занят; всего две цели | маски полей, обязательный сброс флагов A1F/A2F |
| Софтовый: сравнение в millis-цикле | целей сколько угодно, будни и snooze одной логикой | скетч должен крутиться: сон или зависание глушит звонок |
| Гибрид: INT/SQW будит плату | регистр ловит момент, скетч добивает будни и отсрочки | провод на пин прерывания и разбор двух флагов |
- 1. Почему цель сверяют по >=, а не по ==?
- 2. Что возвращает dayOfTheWeek() в RTClib на плате в субботу?
- 3. Что делает кнопка snooze со скетчевой целью будильника?
- 4. Где софтовый будильник в millis-цикле честно проигрывает аппаратным регистрам DS3231?
- 5. Как в симуляторе устроена нумерация дней недели для dayOfTheWeek()?
- Софтовый будильник — сверка момента в millis-цикле: на плате оператор >=, в симуляторе разность unixtime
- >= вместо ==: пауза цикла перепрыгивает точную секунду, а окно с флагом «уже звонил» страхует
- Будни: на плате dayOfTheWeek() даёт Вс=0, в симуляторе Пн=0 — массив имён калибруется по известной дате
- Snooze — сдвиг цели TimeSpan(0,0,9,0) с потолком откладываний, иначе дремота до обеда
- Deep sleep — территория регистров A1/A2 и пина INT/SQW: спящий скетч время сам не сверит
Частые вопросы по теме урока
- Почему цель сверяют по >=, а не по ==?
- Момент 06:45:00 живёт одну секунду, а проверка в цикле с задержкой легко попадает в 06:45:01 — равенство промахивается молча. Нестрогое сравнение ловит всё время после цели, а флаг «уже звонил» не даёт окну стрелять повторно. Цикл с паузой может перепрыгнуть точную секунду, а >= держит открытое окно
- Что возвращает dayOfTheWeek() в RTClib на плате в субботу?
- На плате нумерация с воскресенья: Вс=0, Пн=1, Сб=6. В симуляторе движок нумерует с понедельника, там суббота — 5, поэтому и нужна калибровка по известной дате. 6
- Что делает кнопка snooze со скетчевой целью будильника?
- Snooze — сдвиг цели: celi = celi + TimeSpan(0, 0, 9, 0), и через девять минут окно откроется снова. Глушение до завтра — это отдельная ветка «стоп», у них разная семантика. Сдвигает момент звонка на девять минут вперёд
- Где софтовый будильник в millis-цикле честно проигрывает аппаратным регистрам DS3231?
- Пока скетч спит или висит, сравнение в loop не выполняется, и звонок промолчит. Регистры Alarm 1/2 и пин INT/SQW сверяют время на стороне чипа и будят плату прерыванием. В глубоком сне: скетч стоит, а регистры сверяют время сами
- Как в симуляторе устроена нумерация дней недели для dayOfTheWeek()?
- Движок нумерует с понедельника: 0 — ПН, 4 — ПТ, 5 — СБ, 6 — ВС. Поэтому режим «только будни» в сим-скетче проверяет dn <= 4, а на плате — d >= 1 && d <= 5. С понедельника: ПН = 0, ВС = 6