Привет, Хабр.

Я эмбеддед-разработчик (если что, это те странные ребята, что возятся с чипами для стиралок, Ардуино-поделками и умными домами), хочу поделиться опытом по созданию небезынтересного девайса. Название статьи вполне описывает его суть.

Для начала немного пояснений. Это не пет-проект, а проприетарное поделие, поэтому аппаратная часть опущена. Всему есть открытые аналоги, ссылки на них даны по ходу и в конце. Но мне хотелось поделиться своими страданиями код во многом основан на open-source, и я по идее обязан им поделиться. Так что вот ссылка на гит.

Nota bene: я НЕ советую выбранное решение по умолчанию. ATtiny45 - это древняя хре... deprecated MCU (извиняюсь за субъективизм) со всеми вытекающими, и лично я бы сработал на STM32. Но кровавый энтерпрайз, все дела - схемотехникам было удобнее сделать на AVR, и в серии такое изделие компактней и дешевле.

И их можно понять. Простите, коллеги, за эту боль...
И их можно понять. Простите, коллеги, за эту боль...

Конечно, это доставило немало головняка вследствие ограничений контроллера. Но большинство шишек, надеюсь, уже собраны и возможным последователям не достанутся. Само изделие вышло неплохим - компактное, простое в ПО и схемотехнике, надежное и функциональное. Иначе я бы и не пошел с ним в люди. В дальнейшем будем называть его ИК-приемником.

Описание ИК-приемника

Устройство совмещает в себе функции приемника-декодера команд обычного ИК-пульта и стандартной HID-совместимой USB-клавиатуры. Стандартная с точки зрения ОС, но не способа ввода. Привычного ввода клавишами нет - их заменяют команды протокола NEC (пульта). По сути, на плате нет ничего, кроме микроконтроллера (МК), ИК-датчика и пассивной обвязки.

Прямого аналога ПО девайса в открытом доступе я не нашел (хотя, возможно, надо было поискать повнимательнее), но примеров реализации отдельных функций было достаточно. Ссылки на них тоже будут.

Зачем он вообще нужен? Сама по себе это составная часть более серьезной системы и, вероятно, в таком качестве понадобится и вам. Его можно использовать для удаленного включения/отключения ПК пультом либо ходьбы по GUI-меню какого-нибудь Linux-базированного смарт-TV. Защищенная клавиатура для ввода железно прописанной конфиденциальной информации. Своеобразный интерфейс связи с внешним устройством. Хакерский key-logger (осуждаю). Просто just for lulz использовать пульт вместо клавиатуры. Вариантов придумать можно много...

Ликбез

Стек технологий довольно разнообразен, поэтому поясню некоторые нюансы. Если для вас это азбучные истины, то раздел можно пропустить. Статья рассчитана скорее на начинающих разработчиков и заинтересованных гиков, которым не помешают подробные разъяснения.

Немного про USB и HID:

Шина USB, хоть и соединяет только хост с девайсом, имеет древовидную сетевую топологию, где корнем является единственный Host, узлами - Hubs, а конечными адресатами - Endpoints типа Control, Interrupt и Bulk. Нам потребуется только 2 endpoints - Control и Interrupt. Control для служебного обмена, им занимается библиотека vusb. Interrupt же для передачи данных в направлении Device-to-Host. Девайс сам по себе не может инициировать прерывание, что в случае клавиатуры является проблемой. Но хост периодически опрашивает его, и в случае поднятого флага прерывания девайса забирает данные.

Спецификация USB в части класса Human Interface Device (HID) описывает, соответственно, широкий спектр стандартных USB устройств-посредников между ЭВМ и человеком. Благодаря этому нам не требуется писать драйвер для ОС - стандартные драйвера HID mouclass.sys/kbdclass.sys/'etc'.sys уже интегрированы в систему. Ну или hid-generic и т.д., не суть. С помощью иерархически организованных дескрипторов вы можете объявить свое устройство и как банальную Mouse, и как Spaceship Simulation Device, к примеру. Мы определяем комбинированное устройство Keyboard+Power Controls (второе нужно для изменения уровня питания, т.к. стандартная клавиатура доступа к нему не имеет. Встроенная в клавиатуру кнопка Power вашего ноутбука, скорее всего, напрямую работает с событиями ACPI. Нам такая роскошь недоступна, так что запихнем кроме клавиатуры еще и Power Controls device).

Дескрипторы состоят из полей Usages, определяющих конкретные параметры и кодифицированные в соответствии с HID Usage Tables. Например, максимальный потребляемый ток, Vendor ID, и т.д. Вообще советую бегло пробежать спецификацию Device Class Definition for HID, иначе многое останется непонятным.

Про клавиатуры:

Это низкоскоростное HID-устройство, общающееся с хостом через прерывания. Причем кроме Interrupt-IN, по которому клавиатура передает пакет данных о нажатых клавишах, общение происходит и по Interrupt-OUT - то есть и хост передает сообщения клавиатуре. Но в нашем случае Interrupt-OUT пренебрегаем - это просто команды включения/выключения светодиодов Caps Lock / Num Lock / Scroll Lock, нам это не требуется.

Пакет данных от клавиатуры к хосту состоит из нескольких байт формата report protocol, который вы определяете сами в Report Descriptor. Вообще в Interface Descriptor можно указать, что ваше устройство поддерживает boot protocol и тогда формат данных нужно соблюдать строго. Обычно report protocol определяет посылку из 8 байт:

  • 0-й байт - Modifier keys status - состояние функциональных клавиш;

  • 1-й байт - Reserved field - без разницы;

  • 2-7 байты - Scan codes - до 6 кодов состояния клавиш.

8 бит байта Modifier keys status содержат состояния клавиш Right/Left Ctrl/Shift/Alt/GUI. Как видим, стандартная клавиатура поддерживает до шести нажатых клавиш Алфавиты скан-кодов бывают разные. Список наших можно посмотреть у MightyPork. Но нам еще потребуется байт для Report ID.

Еще стоит отметить, что мы работаем именно с изменением состояний клавиш. Auto-repeat - драйвер засчитывает переданный скан-код клавиши не как одно нажатие, но как ее переход в высокое состояние. То есть, нажатая "А" будет нажата в приложении 'частота_опроса' * 'время_паузы' раз, пока клавиатура не отправит пакет с "0" вместо "А". Бывало, наверное, что посреди матча в CS-GO внезапно пропадал интернет и после его возвращения вы продолжаете идти, пока не нажмете последнюю до лага клавишу? Или, возможно, просто любую - зависит от особенностей обработки нажатий приложением.

Про используемый МК:

ATtiny45 - старенький и простенький микроконтроллер семейства AVR. На AVR основаны знаменитые отладочные платы Arduino (с клонами это может быть не так, и вообще Ардуино можно сделать из многих MCU, той же STM32. Но в оригинальных стоит жирный по меркам AVR ATmega328 с вкачанным ардуиновским бутлоадером - прошивкой, позволяющей закачивать другие прошивки без программатора). На борту ATtiny45 4 КБ флеш-памяти, немного EEPROM, 6 рабочих ножек, 1 таймер, частота до 20 МГц. Разумеется, реализовывать будем USB 1.0 Low-Speed (1.5 Mb/s). Во-первых, большая скорость клавиатуре не нужна, во-вторых, даже Full-Speed (12 Mb/s) ATtiny не вытянет. Внешнего кварца не будет, так что множим внутренний 8МГц RC-генератор на 2. Но 16 на 1.5 нацело не делится, так что будем работать на частоте 16,5 МГц. Такую, разумеется, по дефолту не выставишь, но для работы с USB придется подпилить тактирование с 16МГц.

Обычно большую часть работы с USB берут на себя разнообразные стандартные библиотеки. В нашем случае это уже упомянутая vusb.Нам остается лишь подключить ее, сконфигурировать usbconfig.h и вызывать ее функции в работе.

Про общение по протоколу NEC:

В основном стандарт определяет, какой длительности и в каком порядке инфракрасный светодиод отправителя должен посылать импульсы, чтобы ИК-приемник корректно перевел посылку. Более подробно про протокол и его реализацию на AVR смотри тут, но если кратко - каждый пакет состоит из стартовой последовательности, адреса приемника, инвертированного адреса, байта данных, инвертированного байта. Почему-то только в этой статье упоминалось, что при удержании кнопки посылка не повторяется, а периодически отправляет удлиненную стартовую последовательность. Эх, я много времени убил без этой информации...

Аппаратная часть

Как я уже говорил, в составе платы только МК, ИК-датчик и пассивка, но схему вам я не покажу :). Но не волнуйтесь, страдать с проектированием не требуется: схемами USB на ATtiny завален весь гиковский интернет - вот здесь, например. Только на PB0 еще воткните выход с ИК-датчика типа TSOP361 (ну и фильтры ближе к его +5V не помешают) и на дата-линии USB ставьте резисторы повышенной точности. Как-никак, возможность программной эмуляции USB на таком простом МК сильно расширяет его применимость.

Вообще нормальные разработчики для скорейшего прототипирования использовали бы, во-первых, более жирный MCU, во-вторых, аппаратный USB-преобразователь типа FT232 при нужде (хотя конкретно FT232 я бы не советовал). Но если у вас свои соображения, то двигаемся дальше по пути хардкорной минимизации электроники и кода ценой отлично прячущихся и плохо убиваемых багов при разработке.

Программная часть

Вообще изначально планировалось сделать из платы подобие популярного Digispark'а и сделать все на фреймворке Ардуино, но... 4КБ есть 4 КБ. Было бы неплохо загружать прошивку по USB и попользоваться тучей примеров от ардуинщиков. Но реализовать функционал в этих пределах, тратя флеш-память на бутлоадер и раздутые библиотечные функции, вряд ли возможно.

Так что проект делаем на простом Makefile, тем более собирать нужно только main.с да папку usbdrv. Ну и usbconfig.h - там прописываем usages, с дескрипторами которых работает vusb. Она и тактирование тоже подправит пустыми машинными тактами на основе SOF-пакетов хоста - иначе с точностью RC-генератора в 1% синхронизироваться с USB не получится.

IDE, раз работаем на голом Makefile, можно брать любой - Atmel Studio, VS Code, Arduino IDE, Блокнот... Скорее всего, вам придется делать из Ардуино ISP-программатор (об этом в разделе Прошивка), так что без Arduino IDE не обойтись. Тем более с ней меньше проблем с утилитой avrdude, но нужно ставить ядро для работы с AtTiny45. Я бы посоветовал ATTinyCore. Там заодно и весь процесс описан популярно.

Для нормальной компиляции советую установить драйвер libusb для вашей системы, если вдруг отсутствует. Может прокатит и так, но не помешает.

Итак, к коду, наконец:

// USB report descriptor
const PROGMEM char usbHidReportDescriptor[USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH] = 
{ 
    0x05, 0x01,                    // USAGE_PAGE (Generic Desktop)
    0x09, 0x06,                    // USAGE (Keyboard)
    0xa1, 0x01,                    // COLLECTION (Application)
    0x85, 0x01,					   // Report ID (1)
    0x05, 0x07,                    //   USAGE_PAGE (Keyboard)
    0x19, 0xe0,                    //   USAGE_MINIMUM (Keyboard LeftControl)
    0x29, 0xe7,                    //   USAGE_MAXIMUM (Keyboard Right GUI)
    0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
    0x25, 0x01,                    //   LOGICAL_MAXIMUM (1)
    0x95, 0x08,                    //     Report Count (8)
    0x75, 0x01,                    //     Report Size (1)
    0x81, 0x02,                    //     INPUT (Data,Var,Abs) ,1 byte
    0x95, 0x01,                         //     Report Count (1)
    0x75, 0x08,                         //     Report Size (8)
    0x81, 0x01,                         //     Input (Constant) reserved byte(1)
    0x95, 0x05,                         //	   Report Count (5)	(4?)
    0x75, 0x08,                         //     Report Size (8)
    0x15, 0x00, 		    //   LOGICAL_MINIMUM (0)
    0x25, 0x70,                    //   LOGICAL_MAXIMUM (101)
    0x05, 0x07, 		    //   USAGE_PAGE (Key codes)
    0x19, 0x00,                    //   USAGE_MINIMUM (Reserved (no event indicated))
    0x29, 0x65,                    //   USAGE_MAXIMUM (Keyboard Application)
    0x81, 0x00,                    //   INPUT (Data,Ary,Abs)
    0xC0,    						/* End Collection                           		*/
    // system controls, like power, needs a 3rd different report and report descriptor
    0x05, 0x01,             // USAGE_PAGE (Generic Desktop)
    0x09, 0x80,             // USAGE (System Control)
    0xA1, 0x01,             // COLLECTION (Application)
    0x85, 0x02, 	//   REPORT_ID
    0x95, 0x01,             //   REPORT_COUNT (1)
    0x75, 0x02,             //   REPORT_SIZE (2)
    0x15, 0x01,             //   LOGICAL_MINIMUM (1)
    0x25, 0x03,             //   LOGICAL_MAXIMUM (3)
    0x09, 0x82,             //   USAGE (System Sleep)
    0x09, 0x81,             //   USAGE (System Power)
    0x09, 0x83,             //   USAGE (System Wakeup)
    0x81, 0x60,             //   INPUT
    0x75, 0x06,             //   REPORT_SIZE (6)
    0x81, 0x03,             //   INPUT (Cnst,Var,Abs)
    0xC0                   // END_COLLECTION
};    /* End Collection                           		*/

Как указано в комментарии, это Report Descriptor. По сути, единственный дескриптор, который нужно полностью прописывать самостоятельно. Остальные берет на себя vusb, разве что в usbconfig.h нужно определить заботливо прокомментированные дефайны. Конкретно здесь определяем два репорта - Keyboard с ID = 1 и System Control с ID = 2. Usages организуются по не слишком интуитивной системе. Организация USB-IF уже давно выпустила hid-tool для упрощения работы с ним, но не так давно для этого появился куда более интересный Waratah. Даже обидно за ранние мучения без этой утилиты.

static const uint8_t  keyReport[61][2] PROGMEM = {
	//NUMBER IR_CODE HID_USB_CODE	//Keyboard analog (?/!) IC Button
	/*0 */{	0,			0x2E},      // = key ! zoom+ !
	/*1 */{	0xD8,		0x66},		//Power ! Power button
	/*2 */{	0x78,		0x3B},		//F2 KEY ! Mute button
	/*3 */{	0x20,		0x2D},		// - key ! Zoom- !
	/*4 */{	0xC2,		0x3A},		// F1 key ! Menu button !
	/*5 */{	0x94,		0x45},		// F12 key ! Home button !
	/*6 */{	0xC8,		0x4A},		// Home key ! PC button !
	/*7 */{	0x30,		0x1E},		//1
	/*8 */{	0x88,		0x1F},		//2
	/*9 */{	0x88,		0x04},		//A
	/*10*/{	0x88,		0x05},		//B
	/*11*/{	0x88,		0x06},		//C
	/*12*/{	0x70,		0x20},		//3
	/*13*/{	0x70,		0x07},		//D
	/*14*/{	0x70,		0x08},		//E
	/*15*/{	0x70,		0x09},		//F
	/*16*/{	0x08,		0x21},		//4
	/*17*/{	0x08,		0x0A},		//G
	/*18*/{	0x08,		0x0B},		//H
	/*19*/{	0x08,		0x0C},		//I
	/*20*/{	0xA8,		0x22},		//5
	/*21*/{	0xA8,		0x0D},		//J
	/*22*/{	0xA8,		0x0E},		//K
	/*23*/{	0xA8,		0x0F},		//L
	/*24*/{	0x48,		0x23},		//6
	/*25*/{	0x48,		0x10},		//M
	/*26*/{	0x48,		0x11},		//N
	/*27*/{	0x48,		0x12},		//O
	/*28*/{	0x28,		0x24},		//7
	/*29*/{	0x28,		0x13},		//P
	/*30*/{	0x28,		0x14},		//Q
	/*31*/{	0x28,		0x15},		//R
	/*32*/{	0x28,		0x16},		//S
	/*33*/{	0x98,		0x25},		//8
	/*34*/{	0x98,		0x17},		//T
	/*35*/{	0x98,		0x18},		//U
	/*36*/{	0x98,		0x19},		//V
	/*37*/{	0x68,		0x26},		//9
	/*38*/{	0x68,		0x1A},		//W
	/*39*/{	0x68,		0x1B},		//X
	/*40*/{	0x68,		0x1C},		//Y
	/*41*/{	0x68,		0x1D},		//Z
	/*42*/{	0xB8,		0x27},		//0
	/*43*/{	0xB8,		0x2C},		//Spacebar
	/*44*/{	0x18,		0x37},		//"." key
	/*45*/{	0x18,		0x25},		//"*" key	(Shift + 8)
	/*46*/{	0x58,		0x20},		//# key	(Shift + 3)
	/*47*/{	0x58,		0x1F},		//@ key	(Shift + 2)
	/*48*/{	0x54,		0x2A},		//Backspace ! Backspace button
	/*49*/{	0xA0,		0x52},		//up key
	/*50*/{	0x10,		0x50},		//left key
	/*51*/{	0x50,		0x4F},		//right key
	/*52*/{	0xB0,		0x51},		//down key
	/*53*/{	0x90,		0x28},		//Enter (Return) ! Enter button
	/*54*/{	0x40,		0x42},		//F9 key ! Call button
	/*55*/{	0xF8,		0x29},		//Escape ! Return button
	/*56*/{	0xF0,		0x41},		//F8 key ! Decline button
	/*57*/{	0x60,		0x30},		//keypad SQBRAK RIGHT ! Vol+ button
	/*58*/{	0x80,		0x2F},		//keypad SQBRAK LEFT ! Vol- button
	/*59*/{	0x82,		0x2C},		// Spacebar ! Far/Near button !
	/*60*/{	0xD0,		0x3C},		// F3! LAYOUT !
	};

А это таблица перевода принятых кодов ИК-пульта в скан-коды клавиатуры. Как вы можете видеть, некоторые клавиши многофункциональны. У меня они настроены на конкретный пульт, так что вам придется дешифровать клавиши вашего пульта. Дальше есть включаемый фрагмент, который переводит инверсированный command пульта в мигание подключенного светодиода. И это не костыль, это - стиль. Кстати, PROGMEM означает запись массива напрямую во флеш-память. Примечание для параноиков: если хотите сделать какой-то защищенный девайс, стоит как-то разнести массивы по всей памяти, иначе по дампу вполне можно их восстановить.

Дальше стандартные инициализации USB, прерывания МК и подстройка частоты. Все стандартно, не будем останавливаться. Разве что интересна калибровка, но ей занимается vusb, так что можно не задумываться.

void remote_read() {
unsigned int timer_value;
  if(nec_state != 0){
    timer_value = TCNT0;                         // Store Timer1 value
    TCNT0 = 0;                                   // Reset Timer1
  }
  switch(nec_state){
   case 0 :                                      // Start receiving IR data (we're at the beginning of 9ms pulse)
    TCNT0  = 0;                                  // Reset Timer1
	TCCR0B |= (1 << 2)|(1 << 0);	// set	16.5MHz/1024 prescaler
	TCCR0B &= ~(1 << 1);
	nec_state = 1;                               // Next state: end of 9ms pulse (start of 4.5ms space)
    pulse_counts = 0;
    return;
   case 1 :                                      // End of 9ms pulse
    if((timer_value > 152) || (timer_value < 138)){         // Invalid interval ==> stop decoding and reset

    	nec_state = 0;                             // Reset decoding process
      TCCR0B = 0;                                // Disable Timer1 module
    }
    else
      nec_state = 2;                             // Next state: end of 4.5ms space (start of 562µs pulse)
    return;
   case 2 :                                      // End of 4.5ms space
    if((timer_value > 80) || (timer_value < 65)){

    	repeats++;	//	repeat sequence if 2.2 ms space

      nec_state = 0;                             // Reset decoding process
      TCCR0B = 0;                                // Disable Timer1 module
    }
    else
      nec_state = 3;                             // Next state: end of 562µs pulse (start of 562µs or 1687µs space)
    return;
   case 3 :                                      // End of 562µs pulse
    if((timer_value > 11) || (timer_value < 7)){           // Invalid interval ==> stop decoding and reset
      TCCR0B = 0;                                // Disable Timer1 module
      nec_state = 0;                             // Reset decoding process
    }
    else
      nec_state = 4;                             // Next state: end of 562µs or 1687µs space
    return;
   case 4 :                                      // End of 562µs or 1687µs space
    if((timer_value > 29) || (timer_value < 7)){           // Time interval invalid ==> stop decoding
      TCCR0B = 0;                                // Disable Timer1 module
      nec_state = 0;                             // Reset decoding process
      return;
    }
    if( timer_value > 16)                     // If space width > 1ms (short space)
	{
		nec_code |= (1 << (31 - pulse_counts) );
	}
    else                                        // If space width < 1ms (long space)
	{
		nec_code &= ~(1 << (31 - pulse_counts) );
	}
    pulse_counts++;
    if(pulse_counts > 31){                                  // If all bits are received

    nec_ok = 1;                                // Decoding process OK
	GIMSK &= ~(1 << PCIE);		
				  return;
    }
    nec_state = 3;                               // Next state: end of 562µs pulse (start of 562µs or 1687µs space)
  }
}

А вот и раскодирование сигнала от пульта. Все просто - запускаем таймер, ждем стартовую последовательность. Определяем, передается ли команда, или сигнал удерживаемой кнопки, или что-то невразумительное. И записываем 4 байта команды. Из них нам требуется только 2-й - адрес в случае одного устройства бесполезен, а сверять с инверсиями не нужно. Кстати, repeats считает, сколько раз еще отправить хосту скан-код нажатой клавиши. Его стоит ограничить эмпирически, исходя из частоты usbpoll, чтобы скорость вывода повторяемой клавиши соответствовала ожидаемой.

// during normal operation, a SOF marker is sent by the host every 1ms 
// if no SOF markers are received for a longer period of time, it 
// indicates that the USB bus has been suspended (or disconnected, but 
// then we lose power as well)
  if(usbSofCount == old_sof)
	{
		// I was too lazy to use a timer, so I count how many iterations of
		// the main loop have passed without a SOF marker
		timer_poll_no_usbSOF++;
		if(timer_poll_no_usbSOF > 30 /*&& usb_sleep == USB_ACTIVE*/)	//125ms long?
			usb_sleep = USB_SLEEPING;
	}
	else
	{
		old_sof = usbSofCount;
		timer_poll_no_usbSOF = 0;
		usb_sleep = USB_ACTIVE;
	}

	if(usb_sleep != USB_ACTIVE) {
		if(usb_sleep == USB_SLEEPING && _code) {
			// send USB remote wakeup by signalling SE0 (both D+ and D- low) for 10 ms
			unsigned char tmp0 = PORTB;
			unsigned char tmp1 = DDRB;

			usbPoll();

			cli(); // disable interrupts, prevent V-USB from interfering
			PORTB=(tmp0 & ~(1<<USB_CFG_DMINUS_BIT)) | (1<<USB_CFG_DPLUS_BIT);
			DDRB|=((1<<USB_CFG_DPLUS_BIT)|(1<<USB_CFG_DMINUS_BIT));
			for(i=0;i<10;i++) {
				//wdt_reset();
				_delay_ms(1);
			}
			PORTB = tmp0;
			DDRB = tmp1;
			usb_sleep = USB_WAKING;
			sei();

	        usbPoll();	//	test
		}
	}

	GIMSK |= (1 << PCIE);		//  enables IR receiver

		for (uint8_t k = 0; k < 6; k++)		//	125! ms with enabled interrupts (108 ms - repeat sequences period).
		{
	        usbPoll();
			_delay_ms(25);
			if (repeats > last_repeats)
				{
				last_repeats = repeats;
				k = 0;
				}
		}
		usbPoll();
	if ( (state && usbInterruptIsReady() && repeats<=1) )		// if key released
	{
		state = 0;
		repeats = 1;
		last_repeats = 1;
		for (int t=1; t<8; t++) reportBuffer[t] = 0;
		nec_state = 0;
		usbSetInterrupt(reportBuffer, sizeof(reportBuffer));
	}

Мы используем пакеты Start of Frame (SOF) от хоста, чтобы определить, слушает нас хост или забил. Это может случиться после введения ОС в сон. Для выведения мы посылаем аппаратный запрос на пробуждение - просто ненадолго переводим D+ и D- в low. Разумеется, в биосе должен быть USB Remote Wake Up Enabled. Прерывания от датчика разрешены по сути только в этом фрагменте цикла, чтобы не вылезали когда не просят. Как-никак, vusb уже задействует прерывание INT0, и вмешиваться в его работу не стоит. По сравнению со 150 мс время исполнения остальной части цикла пренебрежимо мало, так что вероятность упустить импульс только теоретическая. Зато появляется возможность достаточно точно рассчитывать время цикла. Из-за дефицита таймеров на AtTiny инкрементируемые переменные *_poll используются как "типа_таймеры" (*_poll > 0 -> инкремент в конце цикла, иначе "типа_таймер" считается отключенным), так что более точно определенная длительность цикла не помешает.

Во втором фрагменте кода обрабатываем ситуацию отпущенной кнопки. Как я уже говорил, чтобы клавиатура перестала выводить скан-код, нужно отправить репорт без этого скан-кода.

		if(nec_ok || timer_poll_switchkey > 1)
		{                                    // If the mcu receives NEC message with successful
			if (nec_ok && timer_poll_switchkey==0)
				{
				command = nec_code >> 8;
				nec_state = 0;
				TCCR0B = 0;                                  // Disable Timer1 module
				}

					for(int j=0; j<=60; j++)
					{
						if ( command == pgm_read_byte(&keyReport[j][0]) )
						{
                            if ((j+alt_counter==46) || (j+alt_counter==47))	//	need for getting start of quevue, else index on the end
                            {
                                if (timer_poll_switchkey == 0)	// is first
                                {
                                    lastKey = pgm_read_byte(&keyReport[j][1]);
                                    alt_counter = 0;
                                    timer_poll_switchkey++;							//	start _timer_
                                    break;
                                }
                                if (timer_poll_switchkey<20 && timer_poll_switchkey>2 && nec_ok)									// choose multibutton
                                {
                                    if (lastKey == pgm_read_byte(&keyReport[j+alt_counter][1]) )	// is not first
                                    {
                                        if (alt_counter > 0) alt_counter = 0;		//offset of letter position
                                        else alt_counter++;
                                    }

                                    lastKey = pgm_read_byte(&keyReport[j+alt_counter][1]);
                                    reportBuffer[1] = 0b00000010;	//Modifier keys status
                                    reportBuffer[3] = lastKey;
                                    break;
                                }
                                else
                                    if (timer_poll_switchkey>=20)		//	end _timer_
                                        {
                                        lastKey = pgm_read_byte(&keyReport[j+alt_counter][1]);	// end waiting, send
                                        alt_counter = 0;
                                        timer_poll_switchkey = 0;							//	end _timer_
                                        reportBuffer[1] = 0b00000010;	//Modifier keys status
                                        reportBuffer[3] = lastKey;
                                        _code = 1;
                                        break;
                                        }
                            }
                            else 	//	end _timer_
                                {
                                lastKey = pgm_read_byte(&keyReport[j][1]);	// end waiting, send
                                alt_counter = 0;
                                timer_poll_switchkey = 0;							//	end _timer_
                                reportBuffer[3] = lastKey;
                                _code = 1;
                                if ((j+alt_counter==46) || (j+alt_counter==47)|| (j+alt_counter==48))
                                    reportBuffer[1] = 0b00000010;	//Modifier keys status
                                else
                                    reportBuffer[1] = 0;	//Modifier keys status
                                break;
                            }
                        }
					}

			if (timer_poll_switchkey > 1 || timer_poll_switchkey==0) 								// if first time, one cycle more
				{
				nec_ok = 0;                                  // Reset decoding process
				address = 0;
				nec_code = 0;
				}
		}

Этот жуткий клубок состояний выбирает, какой скан-код послать на хост в зависимости от ИК-команды, значения "типа-таймера", счетчика повторных нажатий на пульте и свойств нажатой кнопки для обычной ОС. Работает по принципу старых кнопочных телефонов: есть простые кнопки с единственной функцией. А есть многофункциональные, с двумя-четырьмя. Выбираются они по количеству нажатий на кнопку за определенное "типа-таймером" время. В коде еще есть закомментированные фрагменты для декодирования вашего пульта, проверки выдаваемых скан-кодов и работы со специальным режимом, но это забавно, но не особо важно.

		if ((_code == 1 && timer_poll_switchkey==0 && !state) || (repeats > 1))
			{
			state = 1;

				if (usbInterruptIsReady())
					{
					if (reportBuffer[3] == 0x66)
						{
							reportBuffer[0] = 0x2;	// ID
							reportBuffer[1] = 0x1;	// SysCtrl Sleep

							usbSetInterrupt(reportBuffer, /*sizeof(reportBuffer)*/2);

							while(!usbInterruptIsReady());

							reportBuffer[1] = 0;
							usbSetInterrupt(reportBuffer, /*sizeof(reportBuffer)*/2);

						state = 0;
						}
					else
						usbSetInterrupt(reportBuffer, /*sizeof(reportBuffer)*/8);

					}
			timer_poll_switchkey = 0;					// stop timer
			buildReport();
			alt_counter = 0;
			_code = 0;
			command = -1;
			}

		if (timer_poll_switchkey > 0) timer_poll_switchkey++;

Ну тут, в принципе, все очевидно. В случае нажатия на кнопку Power переключаем состояние питания ОС (Power Controls Usage Types типа One Shot Control, а Keyboard - Selector, см. в спецификации HID), другие клавиши - отправляем скан-код, при удержании - прежний репорт. Для управления питания используем id 2, в остальных случаях - 1. В соответствии с нашим Report Descriptor в первом случае отправляем 2 байта, во втором - 8.

Прошивка

Ууу. Прошивка, с учетом отсутствия даже банального UART, выйдет запарной. Процесс я более-менее описал в документе в гите. Кратко пройду по основным моментам:

  1. Нужен программатор ISP. Если его нет, можно сделать из платы Ардуино. Это не проблема, учитывая что его прошивка есть даже в списке стандартных.

  2. Нужна утилита avrdude для прошивки. Она есть в модифицированном виде в том же Arduino IDE, но до него еще нужно добраться, путь можно посмотреть в логе вывода. Можно пользоваться ее консольным вариантом, но неплохо бы понимать, какие параметры выставить. Есть много графических оболочек, мне лично понравилась Sina Prog. Но по умолчанию конфиги для настройки fuse битов могут быть неправильными, рабочие выкладывались тут. Но по-хорошему правильно работать напрямую через makefile - make clean, make fuse, make flash - и готово.

  3. Fuse-биты нужно однократно прошить отдельно. Конфигурация приведена в том же Makefile. Внимание - ошибка в конфигурации может напрочь окирпичить МК! Саму флеш-память тоже стоит беречь - особой стойкостью она не отличается.

  4. Контакт с программатором должен быть супернадежным. Не один раз я соединял стенд с околонулевым сопротивлением проводов, но программатор в упор не видел МК, пока чуть не пошевелить GND, или наоборот, идеально работал, пока не заденешь какой-нибудь надежно соединенный контакт. Возможно, стоило просто поставить пару фильтров, но проще поменьше дергать схему. Нужно поставить перемычку на связанный с программатором и ИК-датчиком PB0 - с подключенным датчиком он отказывался прошиваться.

Заключение

Несмотря на все сомнения, получился действительно интересный девайс. Никогда бы не подумал, что такими простыми инструментами можно сделать что-то реально полезное. Конечно, методы безнадежно устарели, но что древние HID-клавиатуры, что еще более древние ИК-пульты прекрасно себя чувствуют, и могут пережить еще и нас с вами. Возможно, в мифах о великих прогерах-ассемблерщиках прошлого и их деградировавших потомках-айтиRUST'ах действительно что-то есть...

В общем, спасибо за внимание! Надеюсь, для кого-то эта статья стала интересным экскурсом по знакомым всем технологиям, для кого-то - знакомством с парой-другой трюков, а для кого-то - источником вдохновения. Дерзайте!

Список источников:

  1. https://github.com/AndreyGRY/AQ-IRR - ссылка на гит;

  2. https://alexgyver.ru/lessons/naked-chip/ - руководство по прошивке bare-metal-AVR;

  3. http://microsin.ru/content/view/613/44/ - о работе с vusb;

  4. https://www.microchip.com/en-us/product/ATtiny45 - об AtTiny45;

  5. https://wiki.osdev.org/USB_Human_Interface_Devices - о HID;

  6. https://www.keil.com/pack/doc/mw/USB/html/_u_s_b__endpoints.html - про оконечные точки;

  7. https://www.researchgate.net/publication/297739371_The_Development_of_USB_HID_based_Fixed_Cockpit_Simulator - пример HID-устройства;

  8. https://usb.org/document-library/hid-usage-tables-13 - спецификация HID Usage Tables;

  9. https://www.usb.org/document-library/device-class-definition-hid-111 - спецификация Device Class Definition for HID;

  10. https://wiki.osdev.org/USB_Human_Interface_Devices - про boot/report протоколы;

  11. https://gist.github.com/MightyPork/6da26e382a7ad91b5496ee55fdc73db2 - пример списка скан-кодов;

  12. https://habr.com/ru/post/257041/ - про протокол NEC на AVR;

  13. https://codeandlife.com/2012/02/22/v-usb-with-attiny45-attiny85-without-a-crystal/ - схема USB для AtTiny45;

  14. https://www.chipdip.ru/product/ft232rq - про FT232;

  15. https://github.com/microsoft/hidtools - про Waratah;

  16. https://easyelectronics.ru/sinaprog-graficheskaya-obolochka-dlya-avrdude.html - про SinaProg;

  17. https://forum.easyelectronics.ru/viewtopic.php?f=4&t=2975 - рабочая конфигурация avrdude

Комментарии (1)


  1. Opaspap
    05.02.2023 11:13

    Не проще уже просто вставлять в пульты Bluetooth (или хотя бы радио usb, как в копеечных мышках) и забыть об этой древней технологии включать телевизор инфракрасной морзянкой.