Есть у нас в институте старенькое спортивное табло 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-канале ↩
