Есть у нас в институте старенькое спортивное табло eltablo. По нему я, ещё будучи студентом, мячом попадал. И есть (точнее была) у него неприятная проблема: это табло управляется по страшному проводному пульту (как этот пульт работает, я до сих пор не разобрался). Длина провода от пульта до табло на глаз метра 3-4. В стоке его хватает, только чтобы сидеть прямо под ним, что, естественно, неудобно (не видно счёт, неправильный ракурс для судейства и т.д.). Поэтому наши физруки им управляют с противоположной стороны зала, что тоже не совсем удобно, но хотя бы видно, что на этом табло происходит.
В этом, собственно, и заключается проблема: чтобы подключить пульт, пришлось прокинуть не хилой длины проводок, на вскидку, метров 20. Из-за этого табло управляется не всегда стабильно. Это меня и попросили решить. Естественно, я решил, что проводам и пульту место на помойке, а таблом будем рулить по беспроводному соединению и с телефона!
Глянув, что используется в табло в качестве мозга, я пришёл к выводу, что проще будет поменять контроллер.

Изначально я рассматривал esp с поднятой точкой доступа Wi-Fi и красивым веб-интерфейсом управления. Но мне не очень хотелось каждый раз подключаться к точке доступа, да и в целом это мне показалось менее удобным и менее универсальным.
Поэтому я решил рассмотреть вариант с arduino и управлением по UART. Этот вариант позволяет управлять и по Bluetooth, и по проводу. В качестве пульта можно использовать кучу уже существующих приложений, а если потребуется, легко написать свое или даже смастерить кнопочный пульт.
❯ Для начала разберёмся с аппаратным обеспечением этого табло
В нем используются семь семисегментных индикаторов, плюс ещё 4 на часы с доп. секцией на двоеточие.

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

Регистры соединены последовательно шлейфом. Для передачи информации на каждый регистр нужен 1 байт, соответственно, на всё табло надо передавать 11 байт, или 88 бит.
На самом деле, наибольшую сложность вызвало как раз управление этими регистрами.

С распиновкой драйверов проблем не возникло, даташит нашелся легко, а вот с распиновкой шлейфа пришлось повозиться.

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

По сути, из всего шлейфа нам нужны всего 3 провода — latchPin, clockPin и dataPin.
Так как в Arduino уже есть встроенная функция ��аботы со сдвиговыми регистрами, я попробовал использовать её.
void out_595_shift(byte x) { digitalWrite(LATCH_PIN, LOW); // "открываем защелку" shiftOut(DATA_PIN, CLOCK_PIN, LSBFIRST, 0b10110110); // отправляем данные digitalWrite(LATCH_PIN, HIGH); // "закрываем защелку", выходные ножки регистра установлены delay(10); }
И это сработало! Правда, только с одним индикатором, остальные даже не запустились. И с этим пришлось ещё некоторое время разбираться. Зато я определил, какой бит за какой сегмент отвечает.

И собрал библиотеку цифр для этих индикаторов от 0 до 9.
byte numbers[11] = { 0b01111110, //0 0b00010100, //1 0b01011011, //2 0b01010111, //3 0b00110101, //4 0b01100111, //5 0b01101111, //6 0b01010100, //7 0b01111111, //8 0b01110111, //9 0b10000000 //: };
Я попробовал передать 11 байт в цикле, но вменяемых результатов не вышло, табло просто наполнилось восьмерками.

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

Если коротко:
В начале передачи latchPin устанавливается в low;
Перед передачей каждого бита clockPin ставится в low;
dataPin ставится в high или в low в зависимости от бита;
После передачи бита clockPin ставится в high, dataPin в low и clockPin опять в low;
После передачи ставим latchPin в high.
Передача происходит побитово, поэтому удобно хранить биты для всех индикаторов в одном массиве и передавать за раз. Чтобы не заморачиваться, я создал булевый массив bitData на 88 элементов.
Для установки портов latchPin, clockPin и dataPin в значения high и low я написал соответствующие функции (на Arduino я выбрал порты 9, 10 и 11). Для передачи буфера из 88 бит на драйвера dm114 — функцию Show(). А для заполнения буфера цифрами из библиотеки — setData().
bool bitData[88]; //буфер на 88 булевых значений static inline void latchPinH() { bitSet(PORTB, 1); //установка D9 в high } static inline void latchPinL() { bitClear(PORTB, 1); //установка D9 в low } static inline void clockPinH() { bitSet(PORTB, 2); //установка D10 в high } static inline void clockPinL() { bitClear(PORTB, 2); //установка D10 в low } static inline void dataPinH() { bitSet(PORTB, 3); //установка D11 в high } static inline void dataPinL() { bitClear(PORTB, 3); //установка D11 в low } //Принимает байт (из библиотеки цифр) и на какое место (какой индикатор) его поставить void setData(byte number, int num) { for(int i = 0; i<8; i++) { if (number & (0B10000000 >> i)) bitData[num*8-i] = 1; else bitData[num*8-i] = 0; } } void Show() { //Начало передачи latchPin устанавливается в low; // latchPinL(); Почему закоментировано, далее в статье clockPinL(); //Перед передачей первого бита ставим clockPin и dataPin в low dataPinL(); for (int i = 0; i < 88; i++) { if (bitData[i]) dataPinH(); //передаем нужный бит else dataPinL(); //После передачи бита clockPin ставится в high, dataPin в low и clockPin опять в low; clockPinH(); dataPinL(); clockPinL(); } //После передачи ставим latchPin в high. latchPinH(); }
Значения битов для вывода цифр я взял из массива numbers, составленного ранее. И в итоге все заработало!

Вывод цифр практически заработал. Но на табло есть 8 попарно соединенных индикаторов. Для удобной работы с ними, я решил поделить все индикаторы на 7 дисплеев (4 двухразрядных, и 3 одноразрядных) и написать функцию, принимающую число, которое надо вывести, и номер дисплея. Так как передача происходит по очереди, с первого по одиннадцатый индикатор, получается, что в начале передаются данные для последнего индикатора, потом для предпоследнего и т.д., то есть в обратном порядке. Это надо учесть.
А еще у нас есть двоеточие на часах, оно управляется старшим битом из переданных 8 на индикатор. Это надо тоже учесть.
В итоге вышла немного замороченная функция SetNumberToDisplay, но зато удобная для дальнейшей работы.
void SetNumberToDisplay(int number, int displayNumberL,bool AddDot) { //Так как наши экраны могут всего в 2 разряда, перевести число в цифру не сложно int firstDigit = number/10; int secondDigit = number%10; //Так как индикаторы расположены в странном порядке, и передача идет наоборот приходится костылить //указываем, какой индикатор надо использовать для первой и второй цифры int firstDigitNumber = 0; //Номер индикатора для первой цифры int secondDigitNumber = 0;//Номер индикатора для второй цифры switch (displayNumberL) { case 1: firstDigitNumber = 11; secondDigitNumber = 10; break; case 2: firstDigitNumber = -1; //Индикатор с одной секцией secondDigitNumber = 9; break; case 3: firstDigitNumber = 8; secondDigitNumber = 7; break; case 4: firstDigitNumber = -1; //Индикатор с одной секцией secondDigitNumber = 6; break; case 5: firstDigitNumber = 5; secondDigitNumber = 4; break; case 6: firstDigitNumber = 3; secondDigitNumber = 2; break; case 7: firstDigitNumber = -1; //Индикатор с одной секцией secondDigitNumber = 1; break; default: break; } if(firstDigit>0 && firstDigitNumber>=0) setData(numbers[firstDigit], firstDigitNumber); if(displayNumberL == 5 || displayNumberL == 6 && firstDigitNumber>=0) setData(numbers[firstDigit], firstDigitNumber); // костыль на часы для отображения нуля if(!AddDot) setData(numbers[secondDigit], secondDigitNumber); else setData(numbers[10] + numbers[secondDigit], secondDigitNumber); // двоеточие на часах управляется самым первым битов, просто прибавляем её }
Теперь стало возможно задавать значения на семь дисплеев, а не на каждый отдельный индикатор.
❯ Остается только разобраться с логикой работы с��мого табло
Табло имеет два режима. Режим игры — в нем отображаются значения очков, фолов и периодов, ещё можно запустить таймер или секундомер. И обычный режим — в нем отображаются только часы.
Для подсчета очков, периодов и фолов я написал структуру ScoreCounter. А для вывода этих значений — соответствующие функции.
struct ScoreCounter { public: int OwnerScore = 0; int VisitorScore = 0; int OwnerFoul = 0; int VisitorFoul = 0; int Period = 0; void ClearScore() { OwnerScore = 0; VisitorScore = 0; } void ClearFoul() { OwnerFoul = 0; VisitorFoul = 0; } void ClearPeriod() { Period = 0; } void ClearAll() { ClearScore(); ClearFoul(); ClearPeriod(); } } Score; void PrintScore() { SetNumberToDisplay(Score.OwnerScore, 1,false); SetNumberToDisplay(Score.VisitorScore, 3,false);RunСommand } void PrintPeriod() { SetNumberToDisplay(Score.Period, 2,false); } void PrintFoul() { SetNumberToDisplay(Score.OwnerFoul, 4,false); SetNumberToDisplay(Score.VisitorFoul, 7,false); }
Напомню, управлять таблом я планировал через UART при помощи простых текстовых команд. Это оказалась самая простая и самая интересная часть работы. Для чтения команды я написал функцию ReadCommand(), с провода команды читаются через Serial, а для работы по bluetooth я использовал библиотеку SoftwareSerial. Все стандартно.
void ReadCommand() { if (Serial.available()) { String command = Serial.readString(); Serial.println("OK"); RunCommand(command); } if (mySerial.available()) { String command = mySerial.readString(); mySerial.println("OK"); RunCommand(command); } }
Для обработки и выполнения команд используется страшная функция на миллион if — RunCommand(). Для включения/выключения режима игры используется флаг OperatingMode. Для переключения между отображением времени и секундомером/таймером — флаг Chronometer.
void RunCommand(String command) { if(command.indexOf("GAME") == 0) OperatingMode = !OperatingMode; //Вкл-выкл режим игры else if(command.indexOf("OSCOREADD") == 0) Score.OwnerScore+=command.substring(9).toInt(); //ДОБАВИТЬ ОЧКОВ ХОЗЯИНУ else if(command.indexOf("OSCORETAKE") == 0) Score.OwnerScore-=command.substring(10).toInt(); //отнять ОЧКОВ ХОЗЯИНУ else if(command.indexOf("VSCOREADD") == 0) Score.VisitorScore+=command.substring(9).toInt(); //ДОБАВИТЬ ОЧКОВ ГОСТЮ else if(command.indexOf("VSCORETAKE") == 0) Score.VisitorScore-=command.substring(10).toInt(); //отнять ОЧКОВ Гостю else if(command.indexOf("OSCORECLEAR") == 0) Score.OwnerScore=0; //ОЧИСТИТЬ ИГРОКА else if(command.indexOf("VSCORECLEAR") == 0) Score.VisitorScore=0; //ОЧИСТИТЬ ГОСТЯ else if(command.indexOf("OSCORESET") == 0) Score.OwnerScore=command.substring(9).toInt(); //ЗАДАТЬ ОЧКИ ХОЗЯИНУ else if(command.indexOf("VSCORESET") == 0) Score.VisitorScore=command.substring(9).toInt(); //АДАТЬ ОЧКИ ГОСТЮ else if(command.indexOf("PERIODADD") == 0) Score.Period++; else if(command.indexOf("PERIODTAKE") == 0) Score.Period--; else if(command.indexOf("PERIODCLERA") == 0) Score.Period=0; else if(command.indexOf("PERIODSET") == 0) Score.Period = command.substring(9).toInt(); else if(command.indexOf("OFOULADD") == 0) Score.OwnerFoul+=command.substring(8).toInt(); else if(command.indexOf("OFOULTAKE") == 0) Score.OwnerFoul-=command.substring(9).toInt(); else if(command.indexOf("VFOULADD") == 0) Score.VisitorFoul+=command.substring(8).toInt(); else if(command.indexOf("VFOULTAKE") == 0) Score.VisitorFoul-=command.substring(9).toInt(); else if(command.indexOf("OFOULCLEAR") == 0) Score.OwnerFoul=0; else if(command.indexOf("VFOULCLEAR") == 0) Score.VisitorFoul=0; else if(command.indexOf("OFOULSET") == 0) Score.OwnerFoul=command.substring(8).toInt(); else if(command.indexOf("VFOULSET") == 0) Score.VisitorFoul=command.substring(8).toInt(); else if(command.indexOf("CHRONOMETERCL") == 0) ChronometerClear(); else if(command.indexOf("CHRONOMETERSTART") == 0) StartChronometer = !StartChronometer; else if(command.indexOf("CHRONOMETER") == 0) Chronometer = !Chronometer; else if(command.indexOf("SETTIMER") == 0) SetTimerStr(command); else if(command.indexOf("TIMER") == 0) StartTimer =!StartTimer; else if(command.indexOf("CL") == 0) Score.ClearAll(); else if(command.indexOf("DEBUGTIME") == 0) debugTime = !debugTime; else if(command.indexOf("DEBUG") == 0) debug = !debug; else if(command.indexOf("TIMEAD") == 0) SetTimeAdjustment(command.substring(7).toInt()); else if(command.indexOf("TIME") == 0) SetTimeStr(command); else if(command.indexOf("REBOOT") == 0) Reboot(); }
Этими командами можно:
Включать/выключать режим игры;
Увеличивать/уменьшать на указанное значение очки хозяину и гостю;
Обнулять и задавать очки хозяину и гостю;
Увеличивать/уменьшать на указанное значение периоды и фолы;
Обнулять и задавать периоды и фолы;
Очищать счет, периоды и фолы полностью;
Включать, запускать и обнулять секундомер;
Задавать и запускать таймер;
Производить подстройку и настройку времени;
Включать/выключать debug режим.
В loop у нас последовательное чтение команд, вывод счета, работа со временем и обновление индикаторов.
unsigned long oldShow; void loop() { ReadCommand(); //Чтение и выполнение команды if (OperatingMode) //Вывод счета в режиме игры { PrintScore(); PrintPeriod(); PrintFoul(); } if(Chronometer) //Вывод времени или таймера/секундомера { showTimer(); } else { showTime(); } if(StartChronometer) //вкл секундомер { ChronometerTick(); } else if(StartTimer) //вкл таймер { TimerTick(); } if(millis() - oldShow > ShowDelay) //обновление индикаторов по времени { Show(); oldShow = millis(); } clearData(); //чистим буфер delay(5); }
В статье я опустил работу со временем, т.к. и так получилось много кода. По возможностям: есть таймер и секундомер, можно задавать время, и есть функция подстройки. Модуль часов DS1302, мягко говоря, не очень точный и сильно убегает даже за день. Поэтому потребовалась функция подстройки, кстати, в оригинальном контроллере эта функция тоже была. Работает она очень просто: раз в час от времени отнимается или прибавляется до 59 секунд (изначально это происходило раз в сутки, но этого не хватало). Подробнее можно посмотреть в файле watch.ino в репозитории проекта.
Далее собрал «новые мозги» для табло. В наличии у нас контроллер Arduino nano, модуль часов реального времени, bluetooth модуль HC-06 и понижающий стабилизатор для питания всего этого. Проект штучный, плату разводить не стал.

❯ Далее начался процесс тестирования
В процессе тестирования начала происходить какая-то вакханалия :)

После запуска все работало отлично, но спустя время начинали зажигаться лишние сегменты, и так, пока не будут гореть одни восьмерки. С этим багом тоже пришлось немного повозиться, оказалось, в функции Show() для положительных бит я подавал на dataPin высокий уровень сигнала, а для нулей забыл. Из-за чего со временем на сегменты вместо нуля начинала попадать единица (сверху уже исправленный код).
Еще индикаторы неприятно моргали при передачи данных. Чтобы это решить, достаточно было убрать latchPinL(); в начале функции Show(). Возможно, я неверно прозвонил шлейф, соединяющий индикаторы, и latchPin — это на самом деле EnablePin.
После исправления этих нюансов с индикаторами больше проблем не было.

Табло постояло неделю включенным, при этом освещая окна адским красным светом :)

❯ Управление с телефона
Последним этапом был поиск подходящего мобильного приложения. До этого я управлял таблом через bluetooth-терминал, но для постоянного использования это не вариант, т.к. надо, чтобы все функции были забиты на кнопки. Писать свое приложение у меня времени не было, поэтому пришлось долго и упорно штудировать магазин приложений.
Я нашёл два подходящих:

Bluetooth Remote for Arduino:
бесплатное;
можно настраивать несколько интерфейсов;
cо встроенной рекламой;
нельзя импортировать настроенные интерфейсы.
И RoboRemo:
платное, в demo версии можно настроить до 5 кнопок;
можно настраивать несколько интерфейсов;
нет рекламы;
легко импортировать настроенные интерфейсы.
Оба этих приложения при нажатии на кнопку просто отправляют настроенную команду, точно так же, как и bluetooth-терминал. При этом можно настроить и другие элементы.
Изначально использовали первое приложение, но спустя полгода у него начались проблемы со стабильностью, и на телефонах наших физруков начали пропадать настроенные интерфейсы. Бонусом, реклама при открытии приложения постоянно мешала. Ещё немного поискав, мне попалось RoboRemo, и на мой взгляд — это отличное приложение для подобных задач. Реклама больше не мешает, и со стабильностью все отлично.
На данный момент, табло в таком конфиге работает уже второй год. За все это время возникла всего одна аппаратная проблема — от перегрева (а может из-за чего-то другого) сгорел bluetooth-модуль, этому я был удивлен, учитывая, какой колхоз я собрал (думал сдохнет раньше :) ).
Новый модуль расположили более удачно, чтобы он получше продувался, заодно проклеили все соединения для защиты от вибраций. Прошивка за это время показала себя хорошо. Хотел в неё ещё добавить всякие пасхалки и вывод текста, но у меня не хватило времени, физрукам уже нужно было табло для соревнований :(. Зато я получил заслуженный тортик за разработку :).
Полную прошивку из статьи можно найти у меня на GitHub.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩

