Ночник в спальне я собрал за час, а потом неделю дразнил сам себя. В уроке про управление светом у меня уже было готовое решение: жмёшь VOL+ — яркость подрастает, держишь — растёт дальше. На столе оно выглядело безупречно. Но в спальне выяснилось, что между «едва заметно» и «вырви глаз» нет ничего: одно нажатие шагало на 40 единиц из 255, а удержание VOL+ пролетало весь диапазон за полторы секунды, причём ступенями, как будто свет переключали рубильником. Телевизор рядом крутил громкость бархатом — а мой ночник лязгал.
Причина сидела в самом подходе: я менял яркость в темпе пульта, а не в темпе прибора. Каждый кадр — плюс шаг, и кадры при удержании сыплются с той скоростью, которую выбрал инженер пульта, а не я. У NEC это повтор-кадр каждые 108–110 мс, у пультов Sony — все 45 мс, вдвое быстрее: тот же скетч с чужим пультом вёл себя по-другому, и это выбешивало больше всего.
Здесь разберём два лекарства. Первое — гистерезис шага: значение меняется по кадру, но не чаще, чем раз в заданные миллисекунды. Второе — плавный доезд: пульт только задаёт направление и цель, а скорость движения платы считает сама, через millis(). Оба приема опираются на повторы NEC, которые мы разобрали по битам в уроке про протокол, — удержание кнопки перестанет быть вредителем и станет органом управления.
Шаг на каждый кадр: почему яркость прыгает
Сначала числа, чтобы масштаб был виден. Кадр NEC при нажатии приходит один, а дальше, пока кнопку держат, пульт шлёт короткие кадры-повторы каждые 108–110 мс — около девяти штук в секунду. Мой первый скетч добавлял 40 единиц на каждый такой кадр: 255 делилось на семь нажатий, и весь диапазон пролетал за 0,75 секунды удержания. Глаз, который различает на плавном переходе десятка два ступеней, получал семь — отсюда и ощущение рубильника.
Мой второй тупик был логичным и неправильным одновременно: я решил, что раз повторы мешают, их надо выбросить. На кадре с поднятым флагом IS_REPEAT шаг не применял — яркость двигалась ровно на один шаг за нажатие. Красиво на бумаге, мучительно в жизни: от минимума до максимума — семь нажатий, а для точной подстройки под вечерний свет и того больше. Палец стёрся раньше, чем терпение.
Оба тупика сходятся в одну мысль: темп изменения должен задавать скетч, а не пульт. Но выбрасывать повторы тоже глупо — это бесплатный сигнал «кнопку всё ещё держат». Компромисс называется гистерезисом шага: каждый кадр принимаем, а шаг применяем только если с прошлого применённого шага прошло не меньше паузы.
#include <IRremote.hpp> const int IR_PIN = 11;const int LED_PIN = 9; // на Uno не 3 и не 11: Таймер 2 занят IRremoteconst int STEP = 40; // шаг за одно применённое нажатиеconst unsigned long STEP_PAUSE = 150; // гистерезис шага, мс int brightness = 0;unsigned long lastStepAt = 0; void setup() { Serial.begin(9600); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); pinMode(LED_PIN, OUTPUT); analogWrite(LED_PIN, brightness);} void loop() { if (IrReceiver.decode()) { int cmd = IrReceiver.decodedIRData.command; unsigned long now = millis(); if ((cmd == 21 || cmd == 7) && now - lastStepAt >= STEP_PAUSE) { if (cmd == 21) brightness += STEP; if (cmd == 7) brightness -= STEP; brightness = constrain(brightness, 0, 255); lastStepAt = now; analogWrite(LED_PIN, brightness); Serial.print("br="); Serial.println(brightness); } IrReceiver.resume(); }}Что делает таймер в условии: кадры приходят каждые 108 мс, а шаг применяется не чаще раза в 150 мс — значит, часть повторов честно игнорируется, и яркость едет со скоростью примерно 6,7 шага в секунду, или 265 единиц в секунду при шаге 40. От нуля до максимума — около секунды ровного хода, без рывков и без семи нажатий подряд. Число 150 я подобрал под свет: для вентилятора, где каждая ступень слышна, беру 250–300 мс, иначе скорости перескакивают слишком резво. А сам диапазон держит constrain — без него int после 255 спокойно уезжает к 295, и analogWrite молча берёт младший байт: на экране 255, на пине хаос.
Кстати, о границах: почему в скетче ШИМ на пине 9, а не на 3 или 11, хотя это тоже ШИМ-пины Uno. Приёмник IRremote по умолчанию работает на Таймере 2, а он же питает ШИМ пинов 3 и 11 — у меня однажды диод на 11-м просто перестал дотягивать до половины яркости после запуска приёмника. На плате вешайте регулируемый свет на 9 или 10, а в симуляторе таймеров нет, поэтому там диод спокойно живёт на своём 12-м.
Пара STEP и STEP_PAUSE — ручка настройки, и крутится она под нагрузку, а не под пульт. Для света я живу с шагом 40 и паузой 150 мс; для вентилятора, где каждая ступень слышна ухом, беру шаг 20 и паузу 250 мс; для декоративной подсветки ставлю гистерезис пошире, 250 мс, а сглаживание отдаю доезду из второго скетча. Заметьте, как при паузе 150 мс и повторах каждые 108 мс работает фильтр: принимается примерно два кадра из трёх, остальные приходят слишком рано и уходят впустую — это не потери, это и есть цена ровного хода.
Гистерезис — половина решения. Он делает шаги ровными, но каждый шаг всё ещё скачок в 40 единиц. Если хочется, чтобы свет полз, как громкость у телевизора, нужна вторая половина: разделить цель и текущее значение.
Плавный доезд: millis() вместо темпа пульта
Схема у телевизионной громкости такая: пульт двигает целевое значение, а процессор телевизора сам доводит текущую громкость до цели мелкими шажками. Переносим на Arduino: VOL+ поднимает цель на 40 (constrain держит потолок 255), плата каждые 4 мс двигает яркость на одну единицу — это 250 единиц в секунду, весь диапазон за секунду, но без единого видимого ступора. Отпустили кнопку — повторы прекратились, цель замерла, а яркость спокойно доезжает до последнего значения. Отсюда и характерное ощущение «свет догоняет палец», знакомое по любой нормальной технике.
Таймер здесь обязателен, и он на millis(), а не на delay(): delay(4) внутри loop() заморозил бы приёмник, и половина кадров пульта легла бы мимо буфера. Мы это уже проходили в уроке про millis вместо delay — там же лежит общий шаблон пары lastRun/период, который я тут просто переиспользую. Полная версия с направлением выглядит так:
#include <IRremote.hpp> const int IR_PIN = 11;const int LED_PIN = 9;const unsigned long RAMP_MS = 4; // шаг раз в 4 мс = 250 единиц в секунду int brightness = 0; // где диод сейчасint target = 0; // куда он должен доехатьint dir = 0; // -1, 0 или 1unsigned long lastRamp = 0; void setup() { Serial.begin(9600); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); pinMode(LED_PIN, OUTPUT);} void loop() { if (IrReceiver.decode()) { int cmd = IrReceiver.decodedIRData.command; if (cmd == 21) { dir = 1; target = constrain(target + 40, 0, 255); } if (cmd == 7) { dir = -1; target = constrain(target - 40, 0, 255); } if (cmd == 9) { dir = 0; } // EQ — стоп, где стоим IrReceiver.resume(); } if (dir != 0 && brightness != target && millis() - lastRamp >= RAMP_MS) { lastRamp = millis(); brightness += dir; analogWrite(LED_PIN, brightness); } if (brightness == target) { dir = 0; // доехали — гасим движение }}| Стратегия | От 0 до 255 | Когда её беру |
|---|---|---|
| Шаг на каждый кадр | 0,75 с при повторах NEC, рывками | никогда: темп диктует пульт, а не скетч |
| Гистерезис шага 150 мс | около 1,0 с, ровные ступени по 40 | вентиляторы, шкалы, всё ступенчатое |
| Плавный доезд 1 ед. / 4 мс | около 1,0 с, непрерывный ход | свет и звук, где глаз видит переход |
- Темп изменения задаёт скетч, а не пульт: повторы NEC идут каждые 108 мс, у Sony — 45 мс, и скетч не должен от этого зависеть
- Гистерезис шага: кадры принимаются все, а шаг применяется не чаще раза в 150–300 мс — число с потолка, подберите под нагрузку
- constrain(value, 0, 255) обязателен: переполненный int после 255 превращается в мусор на ШИМ-пине
- Плавный доезд: пульт двигает target, плата идёт к нему по одной единице каждые 4 мс через millis-таймер, delay() здесь глушит приёмник
- На Uno IRremote держит Таймер 2 — ШИМ на пинах 3 и 11 мрёт, регулируемый свет вешайте на 9 или 10
- 1. Скетч добавляет 40 к яркости на каждый кадр. Почему при удержании VOL+ свет пролетает диапазон рывками?
- 2. Что делает constrain(brightness + 40, 0, 255)?
- 3. После подключения IRremote светодиод на пине 11 перестал регулироваться. Что случилось?
- 4. Кнопку VOL+ отпустили, а яркость продолжает расти ещё полсекунды. Это баг?
- 5. Чем задаётся скорость плавного хода в версии с доездом?
Теперь у вас на руках два приёма, и между ними есть граница. Гистерезис из первого скетча проще на взгляд и честнее для ступенчатых вещей: скорости вентилятора, яркости шкалы, подстволка в три уровня — там скачок и должен быть скачком. Плавный доезд нужен свету, звуку и всему, что глаз читает как непрерывную величину; плата за него — пара лишних переменных и таймер. В реальных проектах я совмещаю: гистерезис отсекает мусорные кадры, доезд сглаживает то, что прошло фильтр — именно эту комбинацию вы видели в симуляторе.
И последний нюанс, который однажды съел мне вечер: направление dir обязано сбрасываться. В первой версии плавного скетча я обнулял его только по кнопке EQ, а после доезда до target оставлял единицу висеть — стоило потом нажать VOL- один раз, и диод уезжал в ноль без остановки, потому что к минус-направлению прибавлялся старый плюс. Проверяйте все выходы из состояния, а не только очевидный.
Теперь пульт управляет не только включением, но и градацией — и делает это в темпе прибора. Следующий логичный шаг: приборов-то у нас несколько режимов, и листать их кнопкой на корпусе неудобно. В следующем уроке соберём меню: CH+ и CH- листают пункты на экране, OK запускает режим, EQ возвращает назад, а всё это держится на маленьком конечном автомате. А таблицу кодов своих кнопок, если ещё не снимали, возьмите из первого урока раздела — там коды 21, 7 и 9 как раз наши.
Частые вопросы по теме урока
- Скетч добавляет 40 к яркости на каждый кадр. Почему при удержании VOL+ свет пролетает диапазон рывками?
- Удержание — это около девяти кадров в секунду, каждый прибавляет 40: семь применений до потолка за 0,75 секунды. Скорость диктует пульт — так делать не надо. Пульт шлёт повтор-кадры каждые 108–110 мс, и каждый кадр даёт новый шаг
- Что делает constrain(brightness + 40, 0, 255)?
- Constrain — это просто срез по границам. Без него int уедет за 255, а analogWrite возьмёт от него младший байт — яркость станет непредсказуемой. Не даёт значению выйти за 0 и 255: лишнее срезается по краю
- После подключения IRremote светодиод на пине 11 перестал регулироваться. Что случилось?
- Измерение интервалов IRremote по умолчанию живёт на Таймере 2. Диод переносим на 9 или 10 — там Таймер 1, приёмнику он не нужен. Библиотека заняла Таймер 2, который питает ШИМ пинов 3 и 11
- Кнопку VOL+ отпустили, а яркость продолжает расти ещё полсекунды. Это баг?
- Так и задумано: target замер на последнем повторе, текущее значение догоняет его с постоянной скоростью — ровно так ведёт себя громкость телевизора. Нет: пульт двигал цель, а плата по millis-таймеру доезжает до неё по одной единице
- Чем задаётся скорость плавного хода в версии с доездом?
- Шаг раз в 4 мс даёт 250 единиц в секунду; раз в 10 мс — 100. Приёмник при этом свободен: delay() в loop() тут запрещён. Интервалом millis-таймера: шаг раз в RAMP_MS миллисекунд