В домашнем щитке у меня три действия: свет в коридоре, короткий сигнал и «всё выключить». Кнопки для них нашлись, но вот незадача: пультов в квартире четыре — телевизор, кондиционер, музыкальный центр и брелок, — и семейство голосовало за каждый вариант. Жёсткие константы в скетче означали перепрошивку на каждую смену решения; после третьей итерации я собрал режим обучения: плата сама спрашивает, какая кнопка будет звать действие, и пишет ответ в таблицу привязок. Спор о том, чей пульт главный, закончился за одну вечернюю примерку.
Соседний урок про обучаемый пульт на EEPROM решает ту же задачу надолго: коды уходят в постоянную память и переживают выдёргивание из розетки. Здесь слой другой — примерочный. Таблица живёт в ОЗУ, перезагрузка возвращает заводскую раскладку, а переобучение занимает минуту. Для стадии «решаем, какой пульт достанется щитку» это ровно то, что нужно: решения меняются, ничего не протухает.
Что я называю привязкой: слот — это действие прибора, и оно закреплено жёстко (слот 0 всегда реле, слот 1 всегда сигнал). Обучение назначает слоту кнопку пульта — отвечает на вопрос «чем зовут это действие», а не «что вообще прилетело». Если коды вашего пульта ещё не сняты, начните со сканера кодов с таблицей — он печатает команду каждой кнопки.
Хозяин сессии: адрес как пропуск
Первая ловушка обучения — чужие кадры. В комнате пульты сыпят кадрами мимо: кто-то листает канал, кондиционеру уходит температура, и без фильтра первым «обучится» случайный кадр телевизора. Правило, которое решило проблему: первый кадр после входа в обучение задаёт хозяина сессии — его адрес скетч запоминает, и до конца обучения принимает только его пульт. Кадры остальных адресов игнорируются молча. Механику различения нескольких пультов по адресам разбирает отдельный урок.
Второй фильтр — удержание. Пока кнопку держат, пульт шлёт повторные кадры каждые 108 миллисекунд, и без отсечения одна кнопка записалась бы в три слота подряд. Повтор отличает флаг IRDATA_FLAGS_IS_REPEAT, подробно мы разбирали его в уроке про фильтр удержания; здесь он стоит стражем в обоих режимах — и в обучении, и в рабочем, чтобы удержание не дёргало реле девять раз в секунду.
Отдельный случай — два пульта в руках. Хозяин определится по первому кадру, и если оба лежат на столе, кто станет хозяином — лотерея. Мой порядок: второй пульт убирается в карман до входа в обучение, а смена хозяина делается честно — новый вход, и первый кадр уже от другого пульта. Перекрёстные привязки вроде «свет от телевизионного, сигнал от кондиционерного» возможны, но только через два отдельных прохода и постоянную память: в одной сессии адрес один.
Состояния, таймаут и запись слота
Режим обучения — маленький конечный автомат: ждёт короткого нажатия кнопки платы, заполняет слоты слева направо, возвращается к работе. Нажатие короткое, в отличие от десятисекундного ритуала EEPROM-версии: этот режим короткоживущий и явный, случайное касание стоит лишь перезапуска заполнения, а не переписи постоянной памяти. Свой таймаут — двадцать секунд тишины: ушёл за пультом, вернулся — режим уже закрыт, нажмите снова. Индикацию ведёт диод на пине 12: мигает — ждём кнопку, погас — режим работы. Скелет автомата тот же, что у светофора на состояниях.
На записи слота стоят три проверки: кадр не повторный, адрес — хозяйский, команда не нулевая. Только после них байт ложится в ячейку массива, скетч печатает, что записано, и переходит к следующему слоту. Полный скетч:
// Привязки кнопок к действиям в RAM; постоянную запись делает EEPROM#include <IRremote.hpp> const int IR_PIN = 11;const int BTN_PIN = 2;const int LED_PIN = 12; // индикатор режимаconst int RELAY_PIN = 7; // слот 0: свет const int SLOTS = 3; // 0 = реле, 1 = сигнал, 2 = всё выклconst unsigned long LEARN_TIMEOUT = 20000; byte bindings[SLOTS]; // команда пульта для слота; 0 = пустоbool learning = false;bool haveHost = false;uint16_t hostAddr = 0;int slot = 0;unsigned long learnSince = 0;bool prevBtn = HIGH;bool relayOn = false; void setup() { Serial.begin(9600); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); pinMode(BTN_PIN, INPUT_PULLUP); pinMode(LED_PIN, OUTPUT); pinMode(RELAY_PIN, OUTPUT); Serial.println("rabota. korotkoe nazhatie knopki - obuchenie");} void loop() { bool pressed = digitalRead(BTN_PIN) == LOW; if (pressed && prevBtn == HIGH) { learning = true; haveHost = false; slot = 0; learnSince = millis(); Serial.println("OBUCHENIE: nazhmite knopku pulta dlya sloya 0 (rele)"); } prevBtn = pressed; if (learning && millis() - learnSince > LEARN_TIMEOUT) { learning = false; Serial.println("taimaut obucheniya - rabota"); } if (learning) digitalWrite(LED_PIN, (millis() % 500) < 250); else digitalWrite(LED_PIN, LOW); if (IrReceiver.decode()) { bool isRepeat = IrReceiver.decodedIRData.flags & IRDATA_FLAGS_IS_REPEAT; if (!isRepeat) { int addr = IrReceiver.decodedIRData.address; int cmd = IrReceiver.decodedIRData.command; if (learning) { if (!haveHost) { hostAddr = addr; haveHost = true; Serial.print("hozyain sessii: adres 0x"); Serial.println(addr, HEX); } if (addr == hostAddr && cmd != 0) { for (int i = 0; i < SLOTS; i++) { if (i != slot && bindings[i] == cmd && bindings[i] != 0) { Serial.print("vnimanie: eta knopka uzhe v sloye "); Serial.println(i); } } bindings[slot] = cmd; Serial.print("sloy "); Serial.print(slot); Serial.print(" <- komanda 0x"); Serial.println(cmd, HEX); slot = slot + 1; learnSince = millis(); if (slot >= SLOTS) { learning = false; Serial.println("gotovo - rabota"); } else { Serial.print("sloy "); Serial.print(slot); Serial.println(" zhdet knopku"); } } } else { bool hit = false; for (int i = 0; i < SLOTS; i++) { if (bindings[i] == cmd && bindings[i] != 0) { hit = true; if (i == 0) { relayOn = !relayOn; digitalWrite(RELAY_PIN, relayOn); Serial.print("sloy 0: svet "); Serial.println(relayOn); } if (i == 1) { // на плате здесь tone() на зуммер digitalWrite(LED_PIN, HIGH); delay(300); digitalWrite(LED_PIN, LOW); } if (i == 2) { relayOn = false; digitalWrite(RELAY_PIN, LOW); Serial.println("sloy 2: vse vyklyucheno"); } } } if (!hit) { Serial.print("komanda 0x"); Serial.print(cmd, HEX); Serial.println(" ne naznachena"); } } } IrReceiver.resume(); }}Дубль-контроль здесь не запрещающий, а предупреждающий: если обучаемая кнопка уже сидит в другом слоте, скетч запишет её и напечатает предупреждение. Запрет я убрал после живого случая: семья захотела одной кнопкой и свет, и сигнал — а скетч упирался. Предупреждение же спасает от главной грабли самодельных привязок — «почему свет и звонок срабатывают вместе»: ответ почти всегда уже на экране, в строке про занятый слот.
Перезагрузка стирает таблицу — и это граница режима, а не недоработка. Для примерки раскладки ОЗУ идеальна: решения меняются без последствий, заводская раскладка возвращается сама. Когда раскладка устоялась, переносите её в постоянную память теми же слотами — маршрут с EEPROM.put описан в уроке по ссылке выше. Полный цикл примерки занимает пару минут: вход кнопкой платы, три щелчка пультом, проверка; неудобно — ещё один вход, три щелчка, готово. Та же попытка на константах — это правка, компиляция, загрузка и десять минут вместо двадцати секунд.
Про кнопку платы и её дребезг. Короткое нажатие без дебаунса здесь безопасно: вход в обучение триггерится фронтом prevBtn, и дребезг даст два-три входа подряд — режим всё равно один и тот же, таймаут просто пересчитается. Но если будете наращивать автомат новыми ветками, поставьте фильтр по времени хотя бы в 50 миллисекунд: в сложном автомате двойной вход при дребезге уже портит состояние, и искать это придётся по живому прибору.
И про честное упрощение RAM-версии: привязка здесь хранит только команду, без адреса пульта. Две кнопки разных пультов с одинаковой командой будут звать одно действие — в примерке это допустимо, а в постоянной версии слот несёт пару «адрес + команда», как в EEPROM-уроке. Обратная сторона упрощения — быстрая печать таблицы: один байт на слот, десять слотов — десять байт ОЗУ, и расширение до восьми действий не трогает ни память, ни логику, только границы цикла.
| Карта кнопок | Где живёт | Переживает перезагрузку | Урок |
|---|---|---|---|
| читалка-сканер кодов | только печать в монитор | не хранит | сканер кодов с таблицей |
| словарь команд в PROGMEM | прошивка скетча | да, до перепрошивки | пульт как панель управления |
| привязки в ОЗУ | массив байт в скетче | нет — заводская раскладка | этот урок |
| слоты кодов в EEPROM | постоянная память платы | да, годами | обучаемый пульт на EEPROM |
Три слота — не предел, а формат: под каждое действие заводится строка таблицы, и прибор растёт без перепрошивки логики. Когда действий становится больше пяти, список кнопок перерастает в меню режимов по ИК: там пульт листает экран, и привязки назначаются на строку меню, а не на глобальный слот.
И правило гигиены, которое далось мне дороже остальных: обучение должно быть явным для руки и скучным для скетча. Явным — чтобы случайный палец не переписал живую раскладку. Скучным — без остроумных таймингов и скрытых состояний: отлаживать обучение придётся в гостях, на чужом пульте и без монитора под рукой. Оба требования проверяются одним способом — отсоедините монитор и пройдите полный цикл: если по диоду и поведению нагрузки понятно, в каком слоте вы стоите, интерфейс удался.
Три частых симптома первых запусков, и все три лечатся печатью. Обучение молчит, хотя кнопки жмутся: проверьте, не режет ли фильтр честный нулевой ваш пульт — снимите читалкой и ослабьте условие. Реле дёргается девять раз в секунду: флаг повтора не проверен в рабочем режиме. Ваша же кнопка печатается как незнакомая: адрес хозяина достался мусорному кадру — перезапускайте обучение, подальше от источников кадров. Каждый симптом виден в мониторе за минуту, если печати расставлены заранее.
- 1. Кадры какого пульта скетч принимает в режиме обучения?
- 2. Почему повторный кадр не записывается в слот?
- 3. Что вернёт скетч после перезагрузки платы?
- 4. Обучаемая кнопка уже сидит в другом слоте. Что делает скетч?
- 5. Как кнопка платы входит в обучение в этом уроке?
- Слот закреплён за действием, обучение назначает ему кнопку: таблица привязок — массив байт в ОЗУ
- Первый кадр после входа задаёт хозяина сессии; чужие адреса молча игнорируются
- Повторные кадры не обучаются и не исполняются — флаг IS_REPEAT стражем в обоих режимах
- Дубль кнопки не запрещён, но печатается предупреждение — половина совместных срабатываний отсюда
- ОЗУ — для примерки раскладки; постоянство делает EEPROM-версия с теми же слотами
Частые вопросы по теме урока
- Кадры какого пульта скетч принимает в режиме обучения?
- Первый кадр задаёт хозяина сессии, его адрес запоминается, остальные адреса молча игнорируются до конца обучения. Без фильтра первым обучится случайный кадр телевизора. того, чей адрес попал первым кадром после входа в обучение
- Почему повторный кадр не записывается в слот?
- При удержании повторные кадры идут каждые 108 мс, и без отсечения одна кнопка заполнила бы все слоты. Флаг отсеивает их и в обучении, и в рабочем режиме. флаг IS_REPEAT помечает удержание — иначе одна кнопка забила бы три слота
- Что вернёт скетч после перезагрузки платы?
- ОЗУ — слой примерки: перезагрузка возвращает заводскую раскладку, переобучение занимает минуту. Постоянную память делает EEPROM-версия из соседнего урока. заводскую раскладку: таблица привязок живёт в ОЗУ
- Обучаемая кнопка уже сидит в другом слоте. Что делает скетч?
- Дубль-контроль предупреждающий, а не запрещающий: совместное назначение иногда нужно. Предупреждение же быстро находит причину «свет и звонок срабатывают вместе». записывает и печатает предупреждение про занятый слот
- Как кнопка платы входит в обучение в этом уроке?
- Режим короткоживущий и явный: случайное касание стоит лишь перезапуска заполнения. Десятисекундный ритуал нужен там, где переписывается постоянная память. коротким нажатием: таймаут 20 секунд вернёт скетч к работе