Попросил меня на днях товарищ помочь с одной задачкой: управлять компом с аудиопроигрывателем, установленном на ноутбуке с Windows, с помощью маленького аппаратного пультика. Просил всякие ИК пульты не предлагать. И сделать AVR-е, коих у него осталось некоторое немалое количество, пристраивать потихоньку надо.


Постановка задачи


Задача, очевидно, делится на две части:


  • Железячное, работающее на микроконтроллере, и
  • Программное, работающее на компьютере и управляющее тем, что на нём находится.

Раз уж работаем с AVR, то почему бы не Arduino?


Поставим задачу.
Аппаратная платформа:
HW1. Управление ведётся кнопками без фиксации;
HW2. Обслуживаем 3 кнопки (в общем случае сколько не жалко);
HW3. Нажатием считается удерживание кнопки не менее 100 миллисекунд;
HW4. Более длинные нажатия игнорируются. Обработка более 1 кнопки за раз не выполняется;
HW5. При нажатии кнопки запускается некоторое действие на компьютере;
HW6. Обеспечить интерфейс связи с компьютером через встроенный Serial/USB-преобразователь;
Программная платформа:
SW1. Обеспечить интерфейс связи с компьютером через выбираемый последовательный порт;
SW2. Преобразовывать приходящие по интерфейсу связи команды в события операционной системы, доставляемые до нужного аудиоплеера.
SW3. Приостановка обработки команд. В том числе по команде с пульта.


Ну и дополнительное требование: если это не вносит серьёзных затрат времени, сделать решения максимально универсальными.


Проектирование и решение


HW1


Кнопки кнопки пребывают в положении "нажато" непродолжительное время. Кроме того, кнопки могут дребезжать (то есть формировать множество срабатываний за короткий промежуток времени из-за нестабильного контакта).
К прерываниям их подключать нет смысла — не те времена отклика нужны, чтобы этим заморачиваться. Будем читать их состояния с цифровых пинов. Для обеспечения стабильного чтения кнопки в ненажатом состоянии необходимо подключить входной пин к земле (pull-down) или к питанию (pull-up) через подтягивающий резистор. Воспользуемся встроенным pull-up резистором не будем делать дополнительный дискретный элемент на схеме. С одной стороны кнопку подключим к нашему входу, другой — к земле. Вот что получается:
Схема подключения кнопки
И так — для каждой кнопки.


HW2


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


Класс кнопки будет выглядеть примерно так:


Код класса Button
class Button
{
public:
    Button(uint8_t pin, ::Command command)
        : pin(pin), command(command)
    {}

    void Begin()
    {
        pinMode(pin, INPUT);
        digitalWrite(pin, 1);
    }

    bool IsPressed()
    {
        return !digitalRead(pin);
    }

    ::Command Command() const
    {
        return command;
    }

private:

    uint8_t pin;
    ::Command command;
};

После этого шага у нас кнопки стали универсальными и безликими, но с ними можно работать единообразно.


Соберём кнопки воедино и назначим им пины:


Button buttons[] =
{
    Button(A0, Command::Previous),
    Button(A1, Command::PauseResume),
    Button(A2, Command::Next),
};

Инициализация всех кнопок делается вызовом метода Begin() для каждой кнопки:


for (auto &button : buttons)
{
    button.Begin();
}

Для того, чтобы определить, какая кнопка нажата, будем перебирать кнопки и проверять, нажато ли что-нибудь. Возвращаем индекс кнопки, либо одно из спецзначений: "не нажато ничего" и "нажато более одной кнопки". Спецзначения, разумеется, не могут пересекаться с допустимыми значениями номеров кнопок.


GetPressed()
int GetPressed()
{
    int index = PressedNothing;
    for (byte i = 0; i < ButtonsCount; ++i)
    {
        if (buttons[i].IsPressed())
        {
            if (index == PressedNothing)
            {
                index = i;
            }
            else
            {
                return PressedMultiple;
            }
        }
    }
    return index;
}

HW3


Кнопки будем опрашивать с некоторым периодом (скажем, 10 мс), и будем считать, что нажатие произошло, если одна и та же кнопка (и ровно одна) удерживалась заданное количество циклов опроса. Делим время фиксации (100 мс) на период опроса (10 мс), получаем 10.
Заведём декрементный счётчик, в который записываем 10 при первой фиксации нажатия, и декрементируем на каждом периоде. Как только он переходит из 1 в 0, запускаем обработку (см HW5)


HW4


Если счётчик уже равен 0, никаких действий не выполняется.


HW5


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


На этом этапе можно реализовать стратегию работы клавиатурой.


Реализация главного цикла
void HandleButtons()
{
    static int CurrentButton = PressedNothing;
    static byte counter;

    int button = GetPressed();

    if (button == PressedMultiple || button == PressedNothing) 
    {
        CurrentButton = button;
        counter = -1;
        return;
    }

    if (button == CurrentButton)
    {
        if (counter > 0)
        {
            if (--counter == 0)
            {
                InvokeCommand(buttons[button]);
                return;
            }
        }
    }
    else
    {
        CurrentButton = button;
        counter = PressInterval / TickPeriod;
    }
}

void loop()
{
    HandleButtons();
    delay(TickPeriod);
}

HW6


Интерфейс связи должен быть понятным и отправителю, и получателю. Поскольку последовательный интерфейс имеет единицу передачи данных в 1 байт и имеет байтовую синхронизацию, то нет особого смысла городить что-то сложное и ограничимся передачей одного байта на команду. Для удобства отладки сделаем передачу одного ASCII-символа на команду.


Реализация на Arduino


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


Полная реализация
const int TickPeriod = 10; //ms
const int PressInterval = 100; //ms

enum class Command : char
{
    None = 0,

    Previous = 'P',
    Next = 'N',
    PauseResume = 'C',

    SuspendResumeCommands = '/',
};

class Button
{
public:
    Button(uint8_t pin, Command command)
        : pin(pin), command(command)
    {}

    void Begin()
    {
        pinMode(pin, INPUT);
        digitalWrite(pin, 1);
    }

    bool IsPressed()
    {
        return !digitalRead(pin);
    }

    Command GetCommand() const
    {
        return command;
    }

private:

    uint8_t pin;
    Command command;
};

Button buttons[] =
{
    Button(A0, Command::Previous),
    Button(A1, Command::PauseResume),
    Button(A2, Command::Next),

    Button(12, Command::SuspendResumeCommands),
};

const byte ButtonsCount = sizeof(buttons) / sizeof(buttons[0]);

void setup()
{
    for (auto &button : buttons)
    {
        button.Begin();
    }
    Serial.begin(9600);
}

enum {
    PressedNothing = -1,

    PressedMultiple = -2,
};

int GetPressed()
{
    int index = PressedNothing;
    for (byte i = 0; i < ButtonsCount; ++i)
    {
        if (buttons[i].IsPressed())
        {
            if (index == PressedNothing)
            {
                index = i;
            }
            else
            {
                return PressedMultiple;
            }
        }
    }
    return index;
}

void InvokeCommand(const class Button& button)
{
    Serial.write((char)button.GetCommand());
}

void HandleButtons()
{
    static int CurrentButton = PressedNothing;
    static byte counter;

    int button = GetPressed();

    if (button == PressedMultiple || button == PressedNothing) 
    {
        CurrentButton = button;
        counter = -1;
        return;
    }

    if (button == CurrentButton)
    {
        if (counter > 0)
        {
            if (--counter == 0)
            {
                InvokeCommand(buttons[button]);
                return;
            }
        }
    }
    else
    {
        CurrentButton = button;
        counter = PressInterval / TickPeriod;
    }
}

void loop()
{
    HandleButtons();
    delay(TickPeriod);
}

И да, я сделал ещё одну кнопку, чтобы иметь возможность приостановки передачи команд на клиента.


Клиент для ПК


Переходим ко второй части.
Поскольку сложного интерфейса нам не надо, и привязка к Windows, то можно пойти разными путями, кому как нравится: WinAPI, MFC, Delphi, .NET (Windows Forms, WPF и т.д.), или же консольки на тех же платформах (ну, кроме MFC).


SW1


Данное требование закрывается через коммуникацию с последовательным портом на выбранной программной платформе: подключаемся к порту, читаем байты, обрабатываем байты.


SW2


Пожалуй, все видели клавиатуры с мультимедийными клавишами. Каждая клавиша на клавиатуре, в том числе мультимедийная, имеет свой код. Самое простое решение нашей задачи — это имитация нажатий мультимедийных клавиш на клавиатуре. С кодами клавиш можно ознакомиться в первоисточнике — MSDN. Осталось научиться их посылать в систему. Это тоже не сложно: есть в WinAPI функция SendInput.
Каждое нажатие клавиши — это два события: нажатие и отпускание.
Если мы пользуемся C/C++, то можно просто подключить заголовочные файлы. На других языках надо сделать проброс вызовов. Так, например, при разработке на .NET придётся импортировать указанную функцию и описать аргументы. Я выбрал .NET за удобство разработки интерфейса.
Я выделил из проекта только содержательную часть, которая сводится к одному классу: Internals.
Вот его код:


Код класса Internals
    internal class Internals
    {
        [StructLayout(LayoutKind.Sequential)]
        [DebuggerDisplay("{Type} {Data}")]
        private struct INPUT
        {
            public uint Type;
            public KEYBDINPUT Data;

            public const uint Keyboard = 1;

            public static readonly int Size = Marshal.SizeOf(typeof(INPUT));
        }

        [StructLayout(LayoutKind.Sequential)]
        [DebuggerDisplay("Vk={Vk} Scan={Scan} Flags={Flags} Time={Time} ExtraInfo={ExtraInfo}")]
        private struct KEYBDINPUT
        {
            public ushort Vk;
            public ushort Scan;
            public uint Flags;
            public uint Time;
            public IntPtr ExtraInfo;

            private long spare;
        }

        [DllImport("user32.dll", SetLastError = true)]
        private static extern uint SendInput(uint numberOfInputs, INPUT[] inputs, int sizeOfInputStructure);

        private static INPUT[] inputs =
        {
            new INPUT
            {
                Type = INPUT.Keyboard,
                Data =
                {
                    Flags = 0 // Push                
                }
            },
            new INPUT
            {
                Type = INPUT.Keyboard,
                Data =
                {
                    Flags = 2 // Release
                }
            }
        };

        public static void SendKey(Keys key)
        {
            inputs[0].Data.Vk = (ushort) key;
            inputs[1].Data.Vk = (ushort) key;
            SendInput(2, inputs, INPUT.Size);
        }
    }

В нём сначала идёт описание структур данных (отрезано только то, что касается клавиатурного ввода, поскольку мы его имитируем), и собственно импорт SendInput.
Поле inputs — массив из двух элементов, будет использоваться для генерации событий клавиатуры. Нет смысла выделять его динамически, если архитектура приложения предполагает, что вызова SendKey в несколько потоков не будет выполняться.
Собственно, дальше дело техники: заполняем соответствующие поля структур виртуальным кодом клавиши и отправляем в очередь ввода операционной системы.


SW3


Требование закрывается очень просто. Заводится флаг и ещё одна команда, которая обрабатывается особым образом: флаг переключается в противоположное логическое состояние. Если он установлен, то остальные команды игнорируются.


Вместо заключения


Улучшайзингом можно заниматься бесконечно, но это уже совсем другая история. Я не привожу здесь проект Windows-клиента, потому как он предоставляет широкий полёт фантазии.
Для управления медиаплеером посылаем один набор "нажатий" клавиш, если надо управлять презентациями — другой. Можно сделать модули управления, собирать их либо статически, либо в виде подключаемых плагинов. Вообще много чего можно. Главное — желание.


Спасибо за внимание.

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


  1. Javian
    04.09.2018 14:13

    Я так понимаю этот пульт встраивается в переднюю панель системного блока?


    1. a-tk Автор
      04.09.2018 14:18

      Не обязательно куда-либо встраивать. По оригинальной задумке постановщика задач это коробочка, лежащая на столе или прикрепленная к стене, и подключаемая к компьютеру посредством USB.


  1. Nuwen
    04.09.2018 14:28

    Просил всякие ИК пульты не предлагать.
    Пример того, как потратить кучу времени и усилий на совершенно бессмысленный каприз. На алиэкспрессе и ибей всевозможные универсальные пульты с приёмниками в пределах 300 рублей продаются. Хочешь с ИК, хочешь — по радио. И никаких клиентов для windows не нужно, работают из коробки.


    1. a-tk Автор
      04.09.2018 14:37

      Не могу согласиться в полной мере. С ебея ждать посылку недели две, а тут за пол-вечера всё готово. Приёмник как минимум должен либо представляться как HID-устройство, либо иметь драйвер (чем не клиент?)
      Да и чем плохо сделать самому да поделиться процессом и результатами с теми, кому это может оказаться потенциально полезно?


      1. Nuwen
        04.09.2018 14:43

        должен либо представляться как HID-устройство
        Не вижу зла.


  1. aamonster
    04.09.2018 14:32
    +2

    HID же. Эмуляция USB-клавиатуры, есть готовая и с примером в ардуиновской либе (для leonardo/pro micro на «хардварном» USB и для digispark на «софтовом»). И никакого дополнительного софта на компьютере.


    1. totuin
      04.09.2018 16:25

      Ну и плюс, леонардо или про микро прекрасно работает на всех платформах (Винда, андроид макось) без всяких драйверов.


      1. aamonster
        04.09.2018 16:33

        Угу. И digispark (который из себя представляет attiny85 и несколько резисторов и диодов) тоже. Да и на atmega v-usb поднимается (как это сделано в usbatp) — просто на случай если «запас AVR» — это именно atmega. Правда, кажется, при этом не получится остаться в рамках arduino, придётся собирать прошивку полновесным gcc (например, используя atmel studio).


  1. AKudinov
    04.09.2018 14:39

    будем считать, что нажатие произошло, если одна и та же кнопка (и ровно одна) удерживалась заданное количество циклов опроса

    Когда по работе экспериментировали с опросом клавиатур, выяснили, что при такой реализации клавиатура получается «тупой» и субъективно «тугой». Лучше нажатие фиксировать сразу, и только после этого начинать считать. И 100мс для антидребезга многовато. 20мс вполне достаточно.


    1. a-tk Автор
      04.09.2018 14:49

      Появится прототип «в железе» (сегодня-завтра) — можно будет и подкрутить эти вещи.


      1. r00tGER
        05.09.2018 08:35

        Вы же сами выше сказали — «пол-вечера всё готово».
        Получается, прототип ещё спаять, потом отладить…


        1. a-tk Автор
          05.09.2018 09:42

          Соединить проводами выводы ардуины — это прототип или нет? Программная часть за полвечера вместе с этой статьёй.


          1. r00tGER
            05.09.2018 11:30

            Цитата дословно:

            за пол-вечера всё готово

            «Всё» — подразумевает, что именно всё и готово. И код, и железо, и даже, что оно уже всё работает.

            А, у вас еще только прототип (не факт, что превратиться в окончательную версию) на подходе.
            Благодаря подобным нюансам сравнение в сроках получается некорректным.


  1. lizarge
    04.09.2018 16:35

    один мой друг[х]


  1. DEM_dwg
    04.09.2018 16:46
    +1

    Чей то не пойму, а на фига???
    Варианты решения…
    1. Пультом будет являться телефон, связь по WI-FI
    На компьютере поднять web сервер + AutoIT
    2. Пультом будет являться телефон, связь по IR, на компе поставить IR приемник с помощью программы на AutoIT опрашивать приемник и выполнять то, что требуется…


    1. a-tk Автор
      04.09.2018 16:56

      Не находите, что такое решение будет довольно тяжёлым, не на пол-вечера работы?


      1. DEM_dwg
        04.09.2018 17:31

        Решение с IR приемником на 1 вечер…
        Решение с web сервером для меня 3-5 вечеров, это все зависит от стека технологий которыми владеет человек.
        Решение очень похоже на недавнее решение у нас в ГС, вы случаем не из ГС??


      1. Olanonymous
        04.09.2018 21:57

        На самом деле, вы переоцениваете сложность такой поделки. Вот когда-то сделанный за три часа от скуки «пульт»: легковесный cgi-сервер, который крутит батники, генерящие вебморду через шаблонизатор и обрабатывающие нажатия. Морда подстраивается под ориентацию телефона: ландшафтная, портретная, имеет виброотклик на нажатие (если юзер разрешит). Конечно же, никакой безопасности, авторизации и прочего. Более опытный человек вообще такой прототип за час от силы набросает.
        google drive (.zip, 3.06mb)


      1. holomen
        05.09.2018 16:14

        По п.1 «работы» минут на пять — установить на телефон и на комп Unified Remote. Ну и еще немного времени потратить на всякие кнопочки.


        1. a-tk Автор
          05.09.2018 20:36

          В тёмном зале управлять происходящим с телефона… Если только кнопками громкости.
          А ещё надо, чтобы оба были в одной сети, что не всегда возможно.

          PS: я не говорю, что это и другие решения — плохие. Я про то, что все они ограничены (и это тоже), но надо чтобы набор ограничений решения согласовывался с ограничениями постановки в каждом конкретном случае.


    1. lastuniverse
      04.09.2018 17:51

      Тоже не пойму зачем такие сложности. Как вариант, пультом будет телефон с KDEConnect, а на компе соответственно дистрибутив linux с KDE. Такое решение позволит не только управлять медиапроигрывателями, но и использовать телефон как клавиатуру и тачпад, перекидывать файлы между телефоном и компом через контекстное меню на компе или через привычное меню «поделится» на андроиде. Имеются и другие плюшки. За подробностями сюда — community.kde.org/KDEConnect


      1. kreo_OL
        04.09.2018 20:51

        окей окей, а винда с маком? в пролете? ну то есть тулза не по теме?)


  1. Kriminalist
    04.09.2018 18:05

    Блин, аэромышь или пульт от медиаплеера решает эту задачу плюс кучу еще.

    У меня такой, а так сотни их.
    image


  1. VT100
    04.09.2018 22:06

    Небольшой HW комментарий.

    Для обеспечения стабильного чтения кнопки в ненажатом состоянии необходимо подключить входной пин к земле (pull-down) или к питанию (pull-up) через подтягивающий резистор. Воспользуемся встроенным pull-up резистором не будем делать дополнительный дискретный элемент на схеме. С одной стороны кнопку подключим к нашему входу, другой — к земле. Вот что получается:

    При этом также желательно обеспечивать наличие минимального тока через замкнутую кнопку. А то могут случиться «непрожимаемые» кнопки. На вскидку — от 0.1 до 1 мА в зависимости от типа кнопки.
    Либо применять другие методы для «самоочистки контаков».


    1. a-tk Автор
      04.09.2018 22:38

      Не совсем понял. Ток должен быть побольше или поменьше?


      1. VT100
        04.09.2018 23:40

        Побольше. Я-бы сказал — не менее 0.5 мА для комнатных условий и 2..3 мА для промышленных (тут ещё и с помехами получше будет).
        Если кнопки недешёвые, то в документации может быть параметр вида «Recommended load», «Minimum switching current» или сноска о режиме измерения к параметру «Contact resistance».


        1. a-tk Автор
          05.09.2018 20:48

          Измерение показало 0.15 мА. Думаю, работать будет достаточно стабильно, но на будущее обязательно учту. Спасибо!


  1. jamakasi666
    05.09.2018 14:09

    У меня аналогичное собрано на базе digispark. Он компактнее, крайне дешевый(лично я взял пару десятков и вышло что то около 80р за шт.) и сразу умеет v-usb. Зацепил на нее IR с понравившимся удобным пультом и пару кнопок на которые назначил управление звуком и выключение звука. Работает из коробки без установки драйверов\софта на всех ОС и даже на андроиде.


  1. PyJo
    05.09.2018 15:25

    А почему было не разобрать дешевую мультимедиа-клавиатуру, вынуть из неё контроллер, и навесить нужные кнопки управления?
    Вся задача свелась бы к изготовлению корпуса с кнопками.