В прихожей у меня годами висела пустая рама, и однажды я решил, что лучше часов на ней ничего не придумать: ленту из шестидесяти пикселей я выгнул в кольцо на пяльцах, спрятал провода за раму и повесил на стену. Первый скетч собрался за вечер и оказался честным до бесполезности: часовая стрелка стояла пикселем на номере часа, минутная — на номере минуты. В 17:40 кольцо показывало «пять» и «сорок», но между ними зияла дыра — глаз читал такие часы с усилием, как рукописный шрифт. Именно тогда стало ясно, что стрелки на дискретном циферблате — арифметика, а не рисование, и вся она умещается в три формулы.
Разберём формулы по порядку: где стоит часовая в 17:40 и почему без дробной части она к вечеру отстаёт почти на час; как сделать минутную плавной без единого лишнего пикселя — перекрытием яркости двух соседей; зачем нужны маркеры четвертей и почему их приходится перерисовывать каждый кадр. Отдельная глава — время само по себе: модуля часов в моих настенных нет, секунды считает millis(), а стартовое время я вбиваю из монитора порта. Для устройства, которое живёт в розетке, это честный компромисс — и я скажу прямо, в какой момент он перестаёт меня устраивать.
Все числа в уроке — с кольца в шестьдесят пикселей: длина не случайна, это готовый циферблат, где один пиксель равен одной минуте и шести градусам. Скетч для платы рисует полный набор — секундную с хвостом, минутную с перекрытием, часовую и маркеры. В симуляторе — та же логика на ускоренном времени, чтобы сутки пролетали за минуту, а не за сутки. Каркас кадра тот же, что во всех эффектах без delay: таймер на millis() решает, когда перерисовать, и между кадрами плата свободна.
Три формулы циферблата
Минутная — самая простая: пиксель с номером, равным минутам. Сложность начинается с плавности: если рисовать её статичной точкой, раз в минуту цвет прыгает на соседний пиксель, и глаз это видит — остальное кольцо-то неподвижно. Лечение стоит одного вызова map(): секунды внутри минуты от нуля до пятидесяти девяти растягиваются в байт 0-255, и этот байт делит яркость между текущим и следующим пикселем. В начале минуты весь цвет стоит на месте, к концу — уже наполовину переехал на соседа, и переход выглядит как медленный перекат, а не как прыжок.
Часовая ползёт непрерывно сама собой, если не забывать про минуты: позиция равна (час % 12) * 5 + минуты / 12. В 17:40 это 25 + 3 = 28-й пиксель, а не 25-й: стрелка уже на трети пути к шести. Целочисленное деление здесь не грубость, а ровно то, что нужно — часовая на настоящих часах тоже движется рывками по двенадцать минут, и никто не замечает подлога. Мой первый вариант без слагаемого минуты/12 к концу часа отставал почти на целый штрих: 55 минут арифметики, растянутые на 12 пикселей, — и выглядел сломанным, хотя формально показывал время верно.
Маркеры четвертей — приглушённые белые точки на 0, 15, 30 и 45 — нужны кольцу, чтобы читаться как часы, а не как абстрактный круг: по ним глаз определяет «половину» и «без четверти». Хвост секундной стрелки стирает их каждый кадр, поэтому перерисовывать их нужно после затухания и до стрелок, каждый кадр без исключений. Цвет — CHSV(0, 0, 50): оттенок нулевой, насыщенность нулевая, яркость пятая часть; как HSV-тройка превращается в цвет, мы подробно разбирали в уроке про колесо оттенков. Забытый маркер проявляется не сразу: через полминуты четверть кольца слепнет, и часы снова становятся головоломкой.
#include <FastLED.h>#define LED_PIN 6#define NUM_LEDS 60 CRGB leds[NUM_LEDS];long simSec = 17L * 3600L + 40L * 60L; // секунды с полуночи: старт 17:40unsigned long lastSec = 0; void setup() { FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(90); // ночник: ярче не надо Serial.begin(115200); Serial.println("set time: HH MM");} void loop() { // установка времени из монитора: два числа через пробел if (Serial.available()) { int h = Serial.parseInt(); int m = Serial.parseInt(); if (h >= 0 && h < 24 && m >= 0 && m < 60) { simSec = (long)h * 3600L + (long)m * 60L; Serial.println("time set"); } } if (millis() - lastSec >= 1000) { lastSec += 1000; // не = millis(): без дрейфа планировщика simSec = (simSec + 1) % 86400L; drawClock(); }} void drawClock() { fadeToBlackBy(leds, NUM_LEDS, 40); // хвост секундной: около 6 звеньев int s = simSec % 60; int m = (simSec / 60) % 60; int h = (simSec / 3600) % 12; for (int q = 0; q < NUM_LEDS; q += 15) { // маркеры четвертей leds[q] = CRGB(12, 12, 12); } leds[s] = CRGB(0, 0, 70); // секундная: тусклый синий byte frac = map(s, 0, 59, 0, 255); // минутная с перекрытием byte vA = 255 - frac; // текущий пиксель тускнеет byte vB = frac; // следующий разгорается leds[m] = CRGB(vA, vA / 2, 0); leds[(m + 1) % NUM_LEDS] = CRGB(vB, vB / 2, 0); int hPos = (h * 5 + m / 12) % NUM_LEDS; leds[hPos] = CRGB(255, 40, 0); // часовая поверх всего FastLED.show();}Порядок в drawClock() держите как в скетче: затухание, маркеры, стрелки, show(). Переставите затухание после стрелок — и свежий цвет войдёт в кадр уже оглушённым на сорок из 255: та же дисциплина, что в конфетти и вихре, только цена промаха здесь не в эстетике, а в читаемости времени. Секундная нарочно тусклая, CRGB(0, 0, 70): она главная на ночнике, но яркая секундная перетягивает глаз на себя, и время считывать становится труднее. Хвост в шесть звеньев fadeToBlackBy(40) добирается сам — хранить историю позиций не нужно.
Время ведёт одна переменная simSec и шаг раз в секунду. Обратите внимание на lastSec += 1000 вместо lastSec = millis(): первая форма не накапливает дрожь планировщика, и за сутки секунды не теряются — та же логика, что в уроке про обратный отсчёт на millis. Установка по Serial — два числа через пробел: ввёл «17 40» — кольцо сразу показывает новое время; parseInt с таймаутом разбирает ввод без ритуалов. Часовая рисуется последней и поверх минутной: в 12:00 обе стрелки стоят на одном пикселе, и побеждает та, что важнее для чтения времени.
Про производительность коротко: кадр часов — затухание шести десятков пикселей плюс полдюжины присваиваний; на Uno арифметика укладывается в микросекунды, и главный бюджет съедает show() — полтора миллиса на передачу шестидесяти пикселей. Раз в секунду это никого не волнует: запас перед тактом тысячекратный. Даже добавив поверх часов ночную ступень яркости, фотодиод и кнопку листать сцены, вы не приблизитесь к потолку кадра — часы прощают всё, что вы на них навешаете, пока каждый кадр остаётся на millis-таймере, а не на delay.
Ход часов в симуляторе
В симуляторе видно главное, ради чего затевалось перекрытие: минутная не прыгает, а перекатывается, и в момент, когда яркость делится пополам между соседями, кольцо читается как настоящие часы, а не как бегущий огонь. Крутите ручку влево — время замедляется до почти настоящего темпа, вправо — сутки пролетают за полминуты, и видно, как часовая равномерно ползёт за минутной. Каркас шага на millis() — из урока про millis вместо delay: пока кадр не подошёл, loop() свободен для кнопок и остальной логики.
Мнение, к которому я пришёл после двух недель на стене: часы на millis() — не костыль, а честный компромисс, пока устройство включено в розетку постоянно и время вы задаёте руками раз в месяц. Месяц мои часы уходили на полторы-две минуты — я подстраивал по Serial при каждой уборке и не страдал. Ломается компромисс на перебоях питания: после каждого моргания света кольцо показывает стартовые 17:40, и вот тогда приходит модуль DS3231 с батарейкой. Как считать время без модуля, куда оно утекает и почему «раз в секунду» и «раз в 1000 мс» не одно и то же — в уроке про часы на millis.
Практическая деталь, о которую спотыкаются уже на стене: днём на прихожей хватает яркости 120, ночью тридцать хватает с избытком, и разница не только в глазах — шестьдесят пикселей тёплых стрелок просят мизер против белой заливки, но потолок яркости всё равно держит блок в спокойной зоне, как считает урок про ток и бюджет. Если захотите автопереход — светлая ступень днём, ночная вечером, — каркас режимов с памятью в EEPROM уже собран в соседних уроках раздела, остаётся вкрутить свои константы.
И про длину кольца. Полсотни и семьдесят пикселей собираются теми же формулами — меняется только константа минут на пиксель. При 120 пикселях один штрих становится полминуты, и перекрытие стоит сделать и на часовой; при 30 минута прыгает через пиксель, и часы читаются хуже, чем хотелось бы. Я держался шестидесяти именно ради арифметики: минуты делятся на пиксели без остатка, формулы проговариваются вслух, любая поправка занимает секунды. Если сомневаетесь в длине — берите шестьдесят, а нестандартный круг собирайте потом, когда формулы станут своими.
Отдельно про цветовую логику стрелок. Часовая у меня красно-оранжевая, CRGB(255, 40, 0), минутная янтарная с перекрытием, секундная тускло-синяя: три несмешивающихся цвета читаются с другого конца комнаты без усилий. Плюс негласное правило шкалы: маркеры холодные, стрелки тёплые — глаз цепляется за тёплое. Если гости путают минутную с часовой, усиливайте разницу движением, а не яркостью: минутная постоянно перекатывается, и подвижность выдаёт её сильнее любого цвета. Такие мелочи решают больше, чем второй ряд ленты вокруг кольца.
| Элемент | Формула | Что даёт и когда ломается |
|---|---|---|
| Минутная стрелка | пиксель = минута | 60 пикселей на круг; без перекрытия цвет прыгает раз в минуту |
| Перекрытие | map(секунды, 0, 59, 0, 255) | Яркость делится между соседями; глаз видит перекат, а не прыжок |
| Часовая | (час % 12) * 5 + минуты / 12 | Ползёт каждые 12 минут; без слагаемого отстаёт на 55 минут к концу часа |
| Хвост секундной | fadeToBlackBy 40 раз в секунду | Около 6 звеньев следа; 255 — мгновенный прыжок, 10 — размытый шлейф |
- 1. Кольцо 60 пикселей, время 17:40. На каком пикселе стоит часовая стрелка?
- 2. Зачем минутной стрелке перекрытие двух соседних пикселей?
- 3. В часах нет модуля RTC. Откуда брать секунды?
- 4. Через сутки кольцо спешит на четыре минуты. Что происходит?
- 5. Маркеры четвертей каждый кадр стирает затухание. Почему они всё же видны?
- Кольцо 60 пикселей: один пиксель — одна минута; часовая = (час % 12) * 5 + минуты / 12
- Плавная минутная — перекрытие: map(секунды, 0, 59, 0, 255) делит яркость между соседними пикселями
- Маркеры четвертей перерисовывают каждый кадр после затухания — хвост их стирает
- Время на millis() уплывает на доли процента резонатора — минуты в сутки; лечится Serial-подстройкой или DS3231
- Порядок кадра: затухание, маркеры, секундная, минутная, часовая, show()
Частые вопросы по теме урока
- Кольцо 60 пикселей, время 17:40. На каком пикселе стоит часовая стрелка?
- Часовая = (час % 12) * 5 + минуты / 12: 25 + 3 = 28. Целочисленное деление двигает стрелку каждые 12 минут. На пикселе 28: (17 % 12) * 5 + 40 / 12
- Зачем минутной стрелке перекрытие двух соседних пикселей?
- map(секунды, 0, 59, 0, 255) делит яркость между текущим и следующим пикселем: переход выглядит перекатом, а не прыжком. Глаз видит плавный перекат вместо прыжка раз в минуту
- В часах нет модуля RTC. Откуда брать секунды?
- millis() отсчитывает секунды сам: стартовое время вбивается вручную, дальше шаг раз в 1000 мс ведёт сутки. Считать из millis(), стартовое время задать по Serial
- Через сутки кольцо спешит на четыре минуты. Что происходит?
- Полпроцента допуска резонатора — до семи минут в сутки. millis() честен, неточен сам кристалл. Керамический резонатор Uno плывёт на доли процента — уход копится в минуты
- Маркеры четвертей каждый кадр стирает затухание. Почему они всё же видны?
- Порядок кадра: затухание, маркеры, стрелки, show(). Пропустите перерисовку — через полминуты четверть кольца слепнет. Их перерисовывают в каждом кадре после fadeToBlackBy