У универсальных пультов из магазинов есть слабое место: они обучаются, пока жива их батарейка, и забывают всё в первый же месяц простоя. Когда у моего друга умер пульт от люстры, я пообещал собрать обучаемый приёмник на Arduino: люстра слушает один-единственный код, а «обучить» её должен любой пульт — хоть от телевизора, хоть от старого музыкального центра. Идея та же, что у магазинных устройств: режим обучения, в котором плата запоминает коды, и рабочий режим, в котором сравнивает с ними каждую нажатую кнопку. Только памятью служит EEPROM платы — коды переживают выдёргивание из розетки.
Разберём весь механизм: вход в обучение зажатием кнопки на десять секунд, запись по шести слотам через EEPROM.put, маркер пустого слота 0xFF, сравнение команд в рабочем режиме с подтверждением писком. Заодно честно определим границы: команда NEC — один байт, и в байтовые слоты ложится идеально, а RAW-коды чужих протоколов длинные, им нужна отдельная оговорка и другой способ хранения, о котором скажу прямо.
Прежде чем писать в память, полезно понять, что вообще лежит в тысяче ячеек EEPROM Uno, — раскладка устройства разобрана в уроке про карту памяти. Наш проект займёт первые двенадцать байт: шесть слотов по два.
Слоты, байты и режим обучения
Проектирую слоты я так: каждый хранит пару «адрес пульта + команда» — два байта, шесть слотов, двенадцать байт памяти. Адрес нужен, чтобы обученный пульт отличался от чужого: сравнение только по команде откроет дверь пульту, который шлёт те же числа. Пару байт в одном вызове кладёт EEPROM.put со структурой — механику put/get с sizeof и типами я разбирать не буду, она живёт в уроке про EEPROM.put и get; здесь берём готовый приём.
Пустой слот узнаётся по маркеру. Свежая ячейка EEPROM читается как 0xFF — заводское состояние, и если адрес слота равен 255, слот пуст. Трюк старый и хрупкий одновременно: обученный пульт с адресом 255 перепутает маркер с данными. У меня такого нет, а у вас, если появится, — ставьте отдельный magic-байт в отдельную ячейку, о котором пишет урок про magic-число и заводские дефолты.
Вход в обучение — зажатие кнопки на плате на десять секунд. Долгое зажатие — не прихоть: нажатие кнопки — обычное дело при монтаже и переносе макетки, и режим записи, который включается от случайного касания, перепишет живую карту кодов. Десять секунд держать сознательно — цена в одну константу HOLD_MS, и в проекте у меня это 10000; при желании правится в 3000 для отладки. Каждая записанная кнопка подтверждается писком 60 мс — писать коды под вой нельзя, но и молча тоже нельзя: без звука не отличить «записалось» от «не долетело».
#define IR_USE_AVR_TIMER1 // приёмник на Timer1: tone() свободно живёт на Timer2#include <IRremote.hpp>#include <EEPROM.h> const int IR_PIN = 11;const int BTN_PIN = 2;const int LED_PIN = 12;const int BUZZ_PIN = 8; const int SLOTS = 6; // слоты 0-5, два байта на слотconst unsigned long HOLD_MS = 10000; // 10 секунд удержания для входа в обучение struct SlotData { // пара: адрес пульта + команда byte addr; byte cmd;}; bool learning = false;bool readyToExit = false; // кнопка отпускалась после входа в обучениеint slot = 0;unsigned long tHold = 0;bool wasHeld = false; void setup() { pinMode(BTN_PIN, INPUT_PULLUP); pinMode(LED_PIN, OUTPUT); pinMode(BUZZ_PIN, OUTPUT); Serial.begin(9600); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); Serial.println("rabota: kody sravniayutsya so slotami"); Serial.println("obuchenie: derzhit knopku 10 sekund");} void loop() { bool held = digitalRead(BTN_PIN) == LOW; if (!learning) { if (held && !wasHeld) tHold = millis(); if (held && millis() - tHold >= HOLD_MS) { learning = true; readyToExit = false; slot = 0; Serial.println("OBUCHENIE: zhalite knopki pulta"); } } else { if (!held) readyToExit = true; if (readyToExit && held && !wasHeld) { learning = false; Serial.println("vyhod iz obucheniya"); } } wasHeld = held; if (IrReceiver.decode()) { if (learning) { SlotData data; data.addr = IrReceiver.decodedIRData.address; data.cmd = IrReceiver.decodedIRData.command; EEPROM.put(slot * sizeof(SlotData), data); tone(BUZZ_PIN, 2000, 60); Serial.print("slot "); Serial.print(slot); Serial.print(": addr "); Serial.print(data.addr); Serial.print(" cmd "); Serial.println(data.cmd); slot = slot + 1; if (slot >= SLOTS) { learning = false; Serial.println("sloty zapolneny - rabota"); } } else { for (int i = 0; i < SLOTS; i++) { SlotData stored; EEPROM.get(i * sizeof(SlotData), stored); if (stored.addr == 255) continue; // пустой слот: свежая ячейка 0xFF if (stored.addr == IrReceiver.decodedIRData.address && stored.cmd == IrReceiver.decodedIRData.command) { tone(BUZZ_PIN, 2000, 40); Serial.print("uznana knopka slot "); Serial.println(i); break; } } } IrReceiver.resume(); } if (learning) { digitalWrite(LED_PIN, (millis() / 200) % 2); // мигание: режим записи } else { digitalWrite(LED_PIN, LOW); }}Три решения в этом скетче связаны с таймерами, адресами и износом. Первое: строка #define IR_USE_AVR_TIMER1 до include — та же разводка таймеров, что в охранном уроке: приёмник уходит на Timer1, и каждый писк tone() не выбивает тик приёма; без этой строки обучение записало бы первый кадр и оглохло до перезагрузки. Второе: put и get кладут структуру целиком по адресу slot * sizeof(SlotData) — пересчитывать байты вручную не нужно, sizeof сам даст два, а за парами «что лежит в какой ячейке» следит карта памяти. Третье: запись в EEPROM не бесплатная — ячейка переживает порядка ста тысяч перезаписей, и на этом расчёте построен целый урок про update и ресурс ячеек; обучение переписывает шесть ячеек считаные разы за год, так что износ здесь не тема, но привычка считать перезаписи — да.
Режим работы прост до занудства: каждый кадр сравнивается со всеми живыми слотами по паре «адрес + команда», совпадение пищит и печатает номер слота. Тот же пульт работает сразу после перепрошивки, другой пульт — после десяти секунд удержания и пяти секунд записи. Занятно, что происходит при частичном обучении: если записали две кнопки и вышли, слоты со второго по пятый остались по 0xFF и рабочая петля их честно пропускает через маркер. Никакой магии стирания не нужно — незаписанные слоты просто не участвуют в сравнении.
Про честную границу хранения. Байтовая пара «адрес + команда» — это NEC и родственные протоколы, где decodedIRData отдаёт ровно эти два байта. А вот RAW-коды из урока про неизвестный пульт длинные: там хранятся длительности импульсов, и кадр может занимать десятки и сотни байт — в двухбайтовый слот он не поместится физически. RAW хранят по-протокольному: длина кадра отдельной ячейкой, дальше сами длительности, и карта памяти проекта пересчитывается заново. Если ваш пульт не NEC — сначала снимите его читалкой и убедитесь, что decodedIRData вообще распознаёт протокол.
| Слот | Адреса EEPROM | Что хранится | Маркер пустоты |
|---|---|---|---|
| 0 | 0–1 | addr + cmd первой обученной кнопки | addr = 255 |
| 1 | 2–3 | addr + cmd второй кнопки | addr = 255 |
| 2–4 | 4–9 | три средних слота, по два байта на каждый | addr = 255 |
| 5 | 10–11 | последний слот, двенадцатый байт карты | addr = 255 |
Режим работы: сравнение и подтверждение
В симуляторе собрана упрощённая байтовая версия: движок эмулирует байтовые ячейки EEPROM через read и update, а put/get со структурами — нет, поэтому в слот кладётся одна команда без адреса. Логика обучения и сравнения та же: десять секунд удержания, писк на каждую запись, в рабочем режиме — узнавание по совпадению. Побудьте студентом своего же прибора: обучите две-три кнопки, выйдите из режима и проверьте узнавание; затем обучите заново другие кнопки и убедитесь, что старая карта полностью переписалась — обучение всегда стартует с нулевого слота.
Отдельный сценарий для любопытных: обучите пульт, затем попробуйте нажимать кнопки другого пульта. Совпадений не будет — при байтовой версии без адреса может повезти с чужой командой, и это честная уязвимость упрощения: в полном скетче выше пара «адрес + команда» отсеивает чужаков, а здесь защищает только таблица кодов. На два пульта в комнате этого хватает не всегда — поэтому полный вариант с адресом я считаю минимально разумным для чего-то постоянно включённого.
- 1. Сколько памяти занимает один слот в упрощённой байтовой версии?
- 2. Почему в симуляторной версии запись идёт через EEPROM.update, а не write?
- 3. Как скетч понимает, что слот пустой?
- 4. Почему RAW-код чужого пульта нельзя записать в двухбайтовый слот?
- 5. Зачем вход в обучение сделан через десять секунд удержания?
- Обучаемый приёмник = два режима: запись по удержанию 10 с и сравнение команд в рабочей петле
- Слоты 0–5, полный вариант хранит пару «адрес + команда» структурой через EEPROM.put/get
- Маркер пустого слота — заводское 0xFF; для пультов с адресом 255 нужен отдельный magic-байт
- #define IR_USE_AVR_TIMER1 до include: писк tone() не выбивает тик приёма — пульт слушает под пищание
- Команда NEC — байт, слотовая схема для неё идеальна; RAW-кадры длинные и хранятся по-протокольному
Этот проект — мой любимый способ показать, зачем EEPROM вообще существует: без него обучение умирало бы с каждой перезагрузкой, с ним приёмник помнит карту неделями. Куда расти дальше: считайте ресурс записи в проектах, где обучение нажимается каждый день, — урок про ресурс и износ ячеек даст числа; а если захочется обучать не шесть кнопок, а целый пульт люстры с диммером, слотовая карта разрастётся в таблицу протоколов, и начинать стоит с RAW-разбора из урока про неизвестный пульт.
Частые вопросы по теме урока
- Сколько памяти занимает один слот в упрощённой байтовой версии?
- Упрощённая версия хранит только команду — decodedIRData.command это байт 0–255. Полная версия кладёт пару «адрес + команда» структурой, два байта на слот. один байт: команда NEC укладывается в 8 бит
- Почему в симуляторной версии запись идёт через EEPROM.update, а не write?
- Ячейка EEPROM переживает порядка 100 000 перезаписей; update пишет только отличающиеся значения и бережёт ресурс. Механика износа разобрана в уроке раздела EEPROM. update перезаписывает ячейку только при изменении значения — меньше износ
- Как скетч понимает, что слот пустой?
- Заводская ячейка EEPROM хранит 0xFF. Полный скетч проверяет addr == 255 и пропускает слот; для страховки от пультов с адресом 255 ставят отдельный magic-байт. по маркеру 0xFF: свежая ячейка EEPROM читается как 255
- Почему RAW-код чужого пульта нельзя записать в двухбайтовый слот?
- RAW хранит длительности импульсов — десятки и сотни байт на кадр. Его кладут по-протокольному: длина отдельной ячейкой, дальше сами длительности, и шлют обратно sendRaw. RAW-кадр — массив длительностей на десятки байт, слот хранит только пару байт
- Зачем вход в обучение сделан через десять секунд удержания?
- Обучение стартует с нулевого слота и переписывает карту целиком. Случайный вход от короткого касания стёр бы записанное — длительное удержание стоит одной константы и защищает данные. чтобы случайное касание кнопки при монтаже не переписало живую карту кодов