Опыт разработки управляющего ПО для квеструма

Последние год-полтора пользуются большой популярностью т.н. квеструмы, квесты «выйди из комнаты» реализованные в реальной жизни. Когда я ходил на первые квесты, большая часть задачек сводилась к поиску ключей и кодов для механических замков, магнитные замки были чем-то крутым и редким, не говоря уже о беспроводных интерфейсах, но чем дальше, тем более технически сложным становится это развлечение. В конце зимы мой друг, работающий в одной из таких контор, попросил помощи в написании управляющей программы для одного из их квестов, поскольку у них слился программист и начали гореть сроки. Задача была интересной, деньги неплохими, да и хорошего человека выручить не грех, поэтому я согласился, хоть и обидно было понимать, что на этот квест я не пойду, так как буду знать весь его сценарий. Сроки изначально ставилися очень сжатые, поэтому для разработки я выбрал привычную среду С++\Qt5.5.
Быстро стало понятно, что я совершил большую глупость, взявшись за работу с не очень хорошо прописанным тз, но подозреваю, что эту ошибку так или иначе совершал каждый, поэтому смысла о ней писать никакого, это не подводный камень, это самые обычные грабли.

С аппаратной точки зрения управляемая часть квеста выглядит следующим образом:

  • Основной компьютер, стоящий в операторской, на котором работает программа
  • Две звуковые карты в нём, к каждой из которых подключено по 5 колонок
  • Телевизор, подключенный вторым экраном
  • Два USB-свистка, являющиеся переходниками с USB на COM-интерфейс
  • Пачка диммеров и релейников, управляемых по MODBUS

В первичной постановке задача выглядела довольно простой, читать данные по MODBUS, писать данные по MODBUS, в нужные моменты проигрывать звук в нужную колонку и видео на второй экран. Как показала практика, всё это действительно делается не очень сложно. Но вот понять, как это сделать не очень сложно — уже не столь банально.
MODBUS

MODBUS по факту является стандартным протоколом связи для промышленной электроники, он реализуется либо поверх TCP либо поверх ком-порта. Возможно, если бы в квесте использовались устройства подключаемый по TCP, то всё было бы немножко проще, а может и нет, не знаю. Мне достался вариант с ком-портом. Плюсы этого протокола — он стандартный. Минусы — он работает через последовательный порт на скорости 9600 бод, что в итоге ограничивает нас примерно 20 запросами в секунду по каждому из портов.
У всех MODBUS-устройств есть несколько наборов регистров. В моём случае были задействованы три: флаговые регистры (хранят булеву величину, могут быть записаны), регистры хранения (хранят целое число, могут быть записаны) и регистры ввода (хранят целое, не могут быть записаны). В квесте стояло три релейника (восемь входных регистров, на которые приходят состояния датчиков, восемь флагов управляющих релейными каналами) и пачка диммеров (в них я по факту использую пять регистров хранения, четыре управляют яркостью на своих каналах, пятый — общей яркостью).

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

Я не испытывал ни малейшего желания формировать MODBUS пакеты вручную, поэтому в итоге использовал библиотеку libmodbus, она достаточно удобна и стабильно работает. Разобраться с ней мне очень помог проект qModMaster, в котором реализованы основные функции чтения и записи, а для отладки я использовал HHD Free Serial Virtual Ports (его бесплатного функционала более чем хватает) и DiagSlave Modbus Slave Simulator. Конечно Modbus Slave из набора Modbus tools гораздо удобнее, но он через месяц бесплатного использования превращается в тыкву, а покупать его ради одного проекта мне как-то не очень хотелось.

Что важно понимать при работе с MODBUS:
1. Выносите работу с каждым портом в отдельный поток. Скорее всего, вам захочется использовать всю пропускную способность порта, даже если у вас всего один порт, то без дополнительных потоков работать с визуальным интерфейсом будет гарантированно невозможно.

2. Подумайте о системе очередей и приоритетах. Я в итоге сделал следующим образом: в классе обрабатывающем порт есть два набора очередей, очереди записи и очереди чтения. В записи первая очередь выделена под разовые задания, которые обязательно должны быть выполнены (замыкание\размыкание реле, например), остальные очереди относятся к конкретным устройствам и используются в ситуации, когда надо писать длинную последовательность значений, но не критично, чтобы они были записаны все (например таким образом реализуется мерцание лампочек, управляемых по диммерам), каждая очередь чтения содержит запросы на чтение с одного конкретного устройства. Также выставлен приоритет чтения, его я просто вшил константой в код, потому что адаптивного метода не придумал, а необходимости пользовательской подстройки всё равно нет. Приоритет чтения означает, что если есть задания и в очереди чтения и в очереди записи, то на каждые X операций записи гарантируется одна операция чтения.

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

3. Не совершайте лишних действий. Если вам не нужен какой-то датчик, не читайте с него, если вы дождались с датчика сигнала об активации и вам не нужен повторный — сотрите из очереди все задания связанные с этим датчиком. Звучит вполне банально, но при первых тестах на железе я столкнулся с ситуацией, когда из-за накопившихся очередей нужный мне датчик начинал опрашиваться минут через пять после его включения. Впрочем, тогда у меня ещё и не было револьверных очередей для чтения.

4. Если вы управляете яркостью света, то знайте, у диммера конечно 256 градаций яркости, но изменение с 0 на 1, с 1 на 10 и с 64 на 255 воспринимаются почти одинаково, когда надо сделать пульсирующий свет или плавное разгорание (которое всё равно правда будет ступенчатым) это необходимо учитывать.

Звук
Проигрывать звук в определённый канал, говорили они. Это будет легко, говорили они.
Пожалуй ничего в этой задаче не подкосило меня так сильно, как звук. Я ожидал, что может быть проблемой выбрать звуковую карту, но не ожидал, что проиграть звук в строго определённый канал так, чтобы он не протекал в другие каналы, будет столь небанально.
Средства Qt отпали сразу, ничего даже близко подобного в этой библиотеке нет. Я полез исследовать звуковые библиотеки, оказалось, что нормальным людям нужно создание трёхмерных сцен, задание движущихся источников звука, но не проигрывание в одну конкретную колонку.
Первый реализованный вариант проходит среди меня, как очень грязный хак. Я брал одноканальные wav файлы, разбирал их и писал новый wav-файл, в котором число каналов указано как 8, в нужный записаны сэмплы, а в остальные — нули. Минусы этого подхода вполне очевидны, разбор звука в любом другом формате резко усложняется, нет единого стандарта на предмет того, какой канал внутри wav-файла пойдёт в какую колонку (то есть при смене звуковой карты возможно всё придётся перенастраивать), требуется переделывать файл каждый раз, когда захочется играть его в другой канал. Этот вариант как-то работал, но откровенно меня не устраивал и я продолжил поиски. Поиски были вознаграждены, я нашёл библиотеку BASS от un4seen developments. В этой прекрасной библиотеке можно при проигрывании звука просто установить нужный канал и получить нужный эффект. Здесь, конечно, тоже есть подводные камни.
Во-первых, проигрывание звука надо выносить в отдельный поток, библиотека этого не делает.
Во-вторых, надо очень внимательно отслеживать и отключать в драйверах звуковой карты всяческие виртуальные 3д-сцены (сюда же относятся всякие твикеры для звука, наподобие DFX), наличие любой такой настройки размазывает звук по каналам (и хорошо если только по соседним, а не вообще по всем).
В-третьих, у BASS есть маленькая незадокументированная особенность. При использовании только базовой библиотеки можно таким образом проигрывать в один канал, но нельзя микшировать звук в несколько каналов, попытка применить флаги нескольких каналов приводит к игнорированию этой настройки библиотекой. К счастью, эта проблема решается с помощью библиотеки BASSmix от того же разработчика. Итоговое проигрывание на нескольких каналах (но в рамках одной звуковухи) выглядит так

    bool res = BASS_SetDevice(device_);//device_ - номер звуковой карты
    if(!res)
    {
        int code = BASS_ErrorGetCode();
        if(code == BASS_ERROR_INIT)
            BASS_Init(device_, 44100, BASS_DEVICE_SPEAKERS, 0, 0);
        else
        {
            //обработка ошибок
        }
    }
    mixer_ = BASS_Mixer_StreamCreate(44100, 8, BASS_MIXER_END);
    if(mixer_ == 0)
    {
            //обработка ошибок
    }
    for(uint j = 0; j < channel_.size(); j++)
    {
        HSTREAM chan = BASS_StreamCreateFile(false, path_.toLocal8Bit().data(),0,0,BASS_STREAM_DECODE|BASS_SAMPLE_MONO);//В микшер добавляются только декодируемые моно-каналы
        res = BASS_Mixer_StreamAddChannel(mixer_, chan, channel_[j]); //channel_ - массив флагов обозначающих каналы
        channels_.push_back(chan);
        if(!res)
        {
            //обработка ошибок        
        }
    }
    res = BASS_ChannelPlay(mixer_, true);
    if(!res)
    {
        //обработка ошибок
    }

Видео

Проигрывание видео тоже оказалось сопряжено с некоторыми проблемами, связанными с требованиями заказчика. Поскольку телевизор вмонтирован в квест и не должен вызывать ощущения экрана, то важно было, чтобы на нём не мелькали никакие окна, а сразу начиналось проигрывание видео. К сожалению, полностью решить эту задачу мне пока не удалось.
Первый вариант был реализован средствами Qt. В целом с ним всё было хорошо, я брал QDesktopWidget, создавал дочерний к нему QVideoWidget, покрашенный чёрным цветом, после чего начинал выводить на него видео с помощью QMediaPlayer. Всё было хорошо, никаких артефактов, никаких мерцаний, но увы, в Qt5.5 воспроизведение видео иногда виснет намертво. Нерегулярно, непредсказуемо и без объявления войны. Причём оно именно виснет, забивая звуковуху заикающимся куском озвучки, который исчезает только после перезагрузки. Не знаю, решили ли этот баг в Qt 5.6, но судя по форумам там внесли другой баг, когда проигрывание видео начинает отжирать все ресурсы процессора, до каких только дотянется, что мне тоже не очень нравится.

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

--qt-minimal-view --no-qt-fs-controller --qt-start-minimized --qt-fullscreen-screennumber=n --fullscreen --play-and-exit --no-osd --no-qt-bgcone filename

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

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

Ещё одна особенность использования этого метода заключается в том, что Qt при получении списка QDesktopWidget’ов или QScreen сортирует их в каком-то своём порядке, никак не связанном с номерацией экранов в системе, а VLC в ключе --qt-fullscreen-screennumber=n хочет как раз системный номер (правда нумерует с 0, а не с 1), поэтому в итоге я получал список QScreen и вырезал номер экрана из названия устройства (ну, а потом ещё вычитал единицу, чтобы получить номер для VLC), выглядит это как-то так

    QList screens = QGuiApplication::screens();
    for(int j = 0; j < screens.size(); j++)
    {
        QString name = screens[j]->name()+" ["
                +QString::number(screens[j]->size().width())+"x"
                +QString::number(screens[j]->size().height())+"]";
        int num = screens[j]->name().section("DISPLAY",-1).toInt();
        ui->videoDeviceList->addItem(name, screens[j]->name().section("DISPLAY",-1));
    }

Подозреваю, что этот метод не сработает под *nix системами, потому что там экраны будут называться не DISPLAY1, DISPLAY2 и т.д., но поскольку программа разрабатывалась чисто под винду и не будет запускаться на других системах, то смысла это учитывать я не вижу.

© Habrahabr.ru