Ночник у изголовья собрался за вечер: светодиодная лента, полевой транзистор, Arduino. Оставалась мелочь — выключатель. Вставать в темноте к макетке было лень, и я приделал к проекту ИК-приёмник с пультом из прошлого урока: одна кнопка — свет, ещё две — ярче и темнее.
Через неделю у ночника обнаружился полтергейст: я нажимал кнопку один раз, а свет иногда мигал — выключался и тут же включался обратно. Симптом был плавающий: из пяти нажатий дважды срабатывало лишнее. Код выглядел честно: decode, команда, переключение. Откуда вторая посылка, если палец уже давно отпустил кнопку?
В этом уроке повесим действия на кнопки через switch, поймаем виновника мигания — автоповтор NEC — и приручим его: удержание кнопки будет плавно менять яркость, а не дёргать свет. Финальный скетч получится на порядок полезнее простого «нажал — загорелось».
switch по команде: каждой кнопке — своё дело
Когда действий больше одного, каскад из if и else if разрастается и читается плохо. У switch с командами из байта два плюса. Первый — он выглядит как меню: кнопка 67 — сделать то, кнопка 21 — сделать сё. Второй — проваленный case: забудете break, и выполнение тихо сползёт в следующий блок, а искать такое сложнее всего, потому что компилятор не ругается. Это не теория — я дважды наступал на эти грабли, пока не выработал привычку ставить break сразу, одной строкой с кодом.
Команды берём из своей таблицы, собранной в прошлом уроке. У типового пульта из набора: 67 — PLAY, 21 — VOL+, 7 — VOL-. Логика первого шага: PLAY переключает свет, кнопки громкости меняют яркость. Пин для светодиода или ленты нужен с ШИМ: на Uno это 3, 5, 6, 9, 10 и 11 — возьмём 9.
Названия команд я сразу заворачиваю в именованные константы над setup(): const byte CMD_PLAY = 67, CMD_BRIGHTER = 21, CMD_DIMMER = 7. Через неделю код с голыми числами превращается в загадку, а с именами — в читаемый сценарий: case CMD_PLAY виден и через полгода, и в чужих руках. Компилятор подставит те же самые байты, так что счастье бесплатное.
#include <IRremote.hpp> const int IR_PIN = 2;const int LED_PIN = 9;bool lightOn = false; void setup() { Serial.begin(9600); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); pinMode(LED_PIN, OUTPUT);} void loop() { if (IrReceiver.decode()) { byte cmd = IrReceiver.decodedIRData.command; switch (cmd) { case 67: // PLAY lightOn = !lightOn; // переключить состояние digitalWrite(LED_PIN, lightOn); break; default: // остальные команды пока игнорируем break; } IrReceiver.resume(); }}Здесь три приёма, которые я тащу из проекта в проект. Первый: команда сразу кладётся в byte cmd — байт ровно под размер команды, в отладке он компактнее. Второй: переключение через lightOn = !lightOn — классический toggle, одна строка вместо веток «если горит — потушить, иначе зажечь». Третий: digitalWrite(LED_PIN, lightOn) — неочевидная, но легальная передача bool прямо в порт: true превращается в HIGH.
Запустите этот скетч в симуляторе и понажимайте PLAY. Всё работает — ровно до тех пор, пока вы случайно не удержите кнопку чуть дольше обычного. Тогда и вылезает полтергейз из вступления: свет мигает сам. Пора смотреть под капот протокола.
Отдельное слово про нагрузку. В симуляторе на пине 9 сидит скромный светодиод; на реальной ленте так делать нельзя — пин Arduino отдаёт максимум 40 мА, а метр ленты ест 400–600 мА. Между ними ставится полевой транзистор: пин управляет затвором, лента питается своим толстым проводом через исток-сток. Общая земля у платы и ленты обязательна — без неё ШИМ работает как попало.
Автоповтор NEC: тупик с delay и честное решение
Удержание кнопки — отдельный случай протокола NEC. Пульт не пересылает все 32 бита заново, а каждые 108–110 миллисекунд шлёт короткий кадр-повтор: «я всё ещё держу ту же кнопку». Библиотека IRremote добросовестно декодирует каждый такой кадр, decode() возвращает ту же команду снова и снова, пока палец на кнопке. Полсекунды удержания — это четыре-пять лишних переключений: вот и всё объяснение мигания.
Мой первый фикс был тупым: после обработки команды я вставил delay(400), чтобы подавить повторы. Получил вторую проблему — нажатия начали теряться. Пульт честно слал кадр, плата спала в delay, команда пропадала, и ночник иногда просто не реагировал с первого раза. Классический тупик: лечим симптом, ломаем соседний орган.
Правильное решение спрятано в поле decodedIRData.flags: бит IRDATA_FLAGS_IS_REPEAT поднят ровно у повторных кадров и опущен у первого нажатия. Логика строится на этом разделении: команды-переключатели реагируют только на первое нажатие, а команды регулировки — на все кадры подряд. Так автоповтор из вредителя превращается в фичу: держите VOL+ — яркость плавно ползёт вверх, отпустили — остановилась. Точно так же ведёт себя ваш телевизионный пульт, когда вы долго держите громкость.
Заметьте: автоповтор ИК-пульта — не дребезг физической кнопки. Дребезг — это когда контакты дребезжат пару десятков миллисекунд при одном нажатии, и его лечат паузой. Здесь кнопка механически идеальна: пульт сам, по протоколу, шлёт повтор, пока его держат. Поэтому и решение лежит на уровне протокола — читать флаг, а не городить таймеры поверх decode(). Когда я это понял, из скетча исчезли три самодельных таймера.
Яркость: analogWrite, потолок 255 и страховка constrain
analogWrite(LED_PIN, brightness) выставляет на пине ШИМ — прямоугольный сигнал, у которого доля «единиц» равна яркости: 0 — свет погашен, 255 — полная мощность. Светодиод мигает так быстро, что глаз видит только среднее. Шаг регулировки — вопрос вкуса, но с условием: для ночника я беру шаг 20 — от нуля до полной яркости за тринадцать нажатий; для рабочего света над столом ставлю 40, иначе до нужного уровня ждать утомительно.
constrain() — страховка от переполнения: без него 250 плюс 20 превратилось бы в 14 — байт переполнился бы, и у полного света яркость вдруг упала. Диапазон PWM честный, от 0 до 255, и constrain держит значение внутри границ, сколько ни жми. И последняя деталь: яркость применяется, только если свет включён — прибавлять яркость выключенной лампе бессмысленно.
Занятный эффект, о котором узнают на видео: снимая ленту на телефон, вы увидите бегущие по свету полосы. Это не неисправность — камера снимает ШИМ покадрово, и вспышки, невидимые глазу, на видео складываются в полосы. Лента при этом работает нормально; а вот если полосит на глазах — ищите плохой контакт или короткий провод питания.
#include <IRremote.hpp> const int IR_PIN = 2;const int LED_PIN = 9;bool lightOn = false;int brightness = 128; void setup() { Serial.begin(9600); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); pinMode(LED_PIN, OUTPUT);} void loop() { if (IrReceiver.decode()) { byte cmd = IrReceiver.decodedIRData.command; bool isRepeat = IrReceiver.decodedIRData.flags & IRDATA_FLAGS_IS_REPEAT; if (cmd == 67 && !isRepeat) { // PLAY: только первое нажатие lightOn = !lightOn; } if (cmd == 21 && lightOn) { // VOL+: повторы только на пользу brightness = constrain(brightness + 20, 0, 255); } if (cmd == 7 && lightOn) { // VOL- brightness = constrain(brightness - 20, 0, 255); } if (lightOn) { analogWrite(LED_PIN, brightness); } else { analogWrite(LED_PIN, 0); } IrReceiver.resume(); }}- Поменяйте шаг яркости с 20 на 40 и на 5. Сравните ощущение: какой шаг удобен для ночника, а какой — для рабочего света. Заодно проверьте границы: дойдёт ли яркость ровно до 255 и 0 при любом шаге — constrain обязан гарантировать обе.
- Добавьте четвёртую кнопку — CH (команда 70) — как переключатель режимов: тусклый свет (64), средний (128), полный (255). Три case и одна переменная режима. Заодно напечатайте текущий режим в Serial: без распечатки режим из трёх значений глазами не проверишь.
- Защитите скетч от чужих пультов: перед switch проверяйте IrReceiver.decodedIRData.address и игнорируйте посылки с чужим адресом. Подсказка: адрес, который печатает читалка из прошлого урока, и есть эталон для сравнения.
- Сделайте плавное затухание: после VOL- добавьте цикл, который снижает яркость от текущей до нуля по 5 единиц с delay(30) между шагами. И заметьте: во время затухания кнопки перестают работать — delay замораживает decode. Как лечить, узнаете в уроке про millis().
- switch по decodedIRData.command — читаемая альтернатива каскаду if; каждый case заканчивайте break
- Удержание кнопки NEC шлёт кадр-повтор каждые 108–110 мс — decode() возвращает команду снова
- Флаг decodedIRData.flags и бит IRDATA_FLAGS_IS_REPEAT отделяют первое нажатие от повтора
- Toggle-командам повторы запрещаем, регулировкам разрешаем: удержание VOL+ плавно поднимает яркость
- analogWrite работает на пинах 3, 5, 6, 9, 10, 11 у Uno; диапазон яркости 0–255
- constrain(значение, 0, 255) защищает от переполнения байта при прибавлении шага
- Имена команд выносите в константы: CMD_PLAY вместо голого 67 — код читается и через полгода
- 1. Свет мигает при нажатии PLAY. В чём причина?
- 2. Как отличить первое нажатие от удержания?
- 3. На каких пинах Uno доступен analogWrite?
- 4. brightness имеет тип byte и равен 250. Чего ждать после brightness = brightness + 20 без constrain?
- 5. Зачем break внутри каждого case?
- 6. Почему для VOL+ повторы полезны, а для PLAY вредны?
Ночник собран окончательно: свет, яркость, регулировка с удержания, готовность к чужим пультам. Дальше по разделу — отправка собственных ИК-команд и управление несколькими приборами сразу; а если ваши устройства уже говорят по радио Wi-Fi, переходите к разделу MQTT: там платы перестают только мигать светом и начинают разговаривать с сервером.
А пока попробуйте режимы из эксперимента номер два — три уровня яркости одной кнопкой. И доведите до конца защиту по адресу: один вечер с чужим пультом из соседней комнаты убедит вас надёжнее любого абзаца. Это уже похоже на настоящий пульт, только умнее — потому что правила поведения вы написали сами.
Частые вопросы по теме урока
- Свет мигает при нажатии PLAY. В чём причина?
- Удержание шлёт кадры-повторы каждые 108–110 мс, и каждый декодированный кадр снова попадает в ту же ветку переключателя. Каждый повторный кадр NEC снова проходит по case 67
- Как отличить первое нажатие от удержания?
- У повторных кадров NEC поднят флаг IS_REPEAT; команда в них та же, поэтому различить их можно только по flags. По флагу decodedIRData.flags и биту IRDATA_FLAGS_IS_REPEAT
- На каких пинах Uno доступен analogWrite?
- ШИМ у Uno делают таймеры, поэтому доступны только шесть пинов: 3, 5, 6, 9, 10 и 11. На 3, 5, 6, 9, 10, 11
- brightness имеет тип byte и равен 250. Чего ждать после brightness = brightness + 20 без constrain?
- Байт не умеет хранить 270: старший бит теряется, и 270 минус 256 даёт 14 — эффект «лампа вдруг потускнела». Переполнение: яркость станет 14 и свет потускнеет
- Зачем break внутри каждого case?
- В C++ case без break — провал в следующий блок: включение света заодно изменит яркость, и искать это сложнее всего. Без него выполнение провалится в следующий case
- Почему для VOL+ повторы полезны, а для PLAY вредны?
- Кадры-повторы имитируют «кнопку всё ещё держат»: для ползунка это удобно, а выключатель от каждого повтора переключается заново. Регулировка должна меняться при удержании, а переключатель — срабатывать один раз