Как аппаратно считывать показания ЖК индикатора?
Идея аппаратно считывать показания с индикатора прибора может показаться полной чушью. Но на самом деле есть очевидные ситуации, когда это полезно.
Если научиться считывать и передавать показания индикатора, то практически любой готовый прибор или устройство можно подключить к интернету вещей или непосредственно к компьютеру для регистрации и обработки данных – даже если изначально это не было предусмотрено в конструкции устройства.
Это могут быть весы, медицинские приборы, промышленное оборудование. Зная принцип считывания данных, можно доработать даже старые устройства со светодиодными индикаторами.
Например, возьмем самые популярные и дешевые китайские весы. Вот такие:

Рис. 1. Бытовые весы, $4
Допустим, мы хотим сделать из них прибор, который автоматически передает вес детали по USB или WiFi. Есть готовая конструкция – корпус, индикатор, кнопки, измерительная часть. И это работает. Можно разработать свою схему и плату весов на микроконтроллере, с трудом втиснуть ее в корпус весов, заморочиться с поддержкой штатного индикатора. А можно один раз научиться считывать показания индикаторов и легко дорабатывать любое устройство. Если в корпусе мало места, достаточно добавить в конструкцию только разъем или вывести плоский шлейф, а всю обработку выполнять внешним модулем. Причем этот модуль может быть универсальным.
Вдобавок, это наглядный и простой пример реверс-инжиниринга.
Примечание: в этой статье мы говорим о считывании показаний индикаторов так называемого статического типа (static LCD), у которых сегменты управляются простым противофазным напряжением относительно подложки. Такими дисплеями оборудовано большинство бытовых приборов. В случае динамического типа индикатора, когда напряжение на подложке и сегментах индикатора имеет сложную ступенчатую форму, считывать показания намного сложнее (и вряд ли имеет смысл). К динамическому типу обычно относятся многосегментные автомобильные дисплеи, индикаторы сложных промышленных приборов, медиацентров и тд.
Приступаем к реверс-инжинирингу
У меня под рукой оказались именно такие весы, как на рисунке. Их конструкция идеально подходит для экспериментов с индикатором. Аккуратно разбираем весы. Нам повезло – мы видим ряд из 16 выводов индикатора, к которым впоследствии можно припаять провода (рис. 2). Плата образца 2007г. В новых весах конструкция платы может быть немного другой.

Рис. 2. Расположение выводов индикатора на плате
Иногда индикатор соединяется с платой гибким шлейфом или через эластичные контакты из токопроводящей резины. В таком случае придется аккуратно припаять провода к дорожкам платы.
Напомню принцип работы ЖК индикатора. Знакоместо состоит из общей подложки (common) и сегментов (segment). Сегментами являются не только элементы цифр, но и любые одиночные символы, произвольно размещенные на площади индикатора. То есть, знакоместо может быть виртуальным, разбросанным по всему индикатору. К одному выводу индикатора подключается несколько сегментов. Какой сегмент будет активен – зависит от выбранной подложки. Таким образом, общее число сегментов индикатора равно произведению количества выводов сегментов на количество выводов подложки.

Рис. 3. Упрощенная диаграмма сигналов ЖК индикатора
Сегмент виден, если напряжение на выводе сегмента находится в противофазе с напряжением на подложке. На рис. 3 показано типичное напряжение на подложке COM и двух сегментах. Сегмент SEG1 не виден, потому что напряжение на его выводе совпадает по фазе с подложкой. Сегмент SEG2 в противофазе и виден на дисплее. Важный нюанс: полярность напряжения необходимо постоянно менять, иначе внутри индикатора начнутся процессы электролиза, которые необратимо испортят индикатор! ЖК индикаторы имеют большую инерцию переключения, поэтому управляющие сигналы меняют полярность с небольшой частотой (20…40 Гц). Сигнал COM для некоторых индикаторов может иметь ступенчатую форму. Это зависит от конструкции индикатора и для нас не имеет значения.
Определяем назначение выводов индикатора
Подключаем осциллограф и последовательно смотрим форму сигнала на выводах индикатора. На выводах 1…4 явно присутствует характерный сигнал подложки (рис. 4). Он не меняется при смене показаний индикатора. На оставшихся выводах мы видим прямоугольные сигналы, которые меняются при смене показаний. Это выводы сегментов. Итак, у нас 4*12 = 48 сегментов индикатора. Аккуратно считаем все сегменты индикатора, включая отдельные сегменты цифр, и убеждаемся, что это действительно так.

Рис. 4. Сигнал на выводах подложки COM1…COM4
Теперь надо определить, какое сочетание подложки и управляющего вывода включает каждый сегмент. Этот этап наиболее творческий и трудный. Мне повезло, что индикатор можно легко выпаять из платы, и поочередно подавая переменное напряжение на выводы, определить адрес каждого сегмента. В вашем случае, возможно, придется временно надрезать проводники платы, а затем восстановить их капельками припоя.
Поскольку напряжение должно быть знакопеременным и не превышать 3 вольта, я использовал Aduino Nano и простейшие резистивные делители (рис. 5):

Рис. 5. Схема для тестирования ЖК индикатора
Пишем простейший скетч для Arduino. Cкетч генерирует противофазные импульсы длительностью 4 мс на выводах D4 и D5.
#define COM 5 #define SEG 4 void setup() { pinMode(COM, OUTPUT); pinMode(SEG, OUTPUT); } void loop() { digitalWrite(COM, LOW); digitalWrite(SEG, HIGH); delay(4); digitalWrite(COM, HIGH); digitalWrite(SEG, LOW); delay(4); digitalWrite(COM, HIGH); digitalWrite(SEG, HIGH); delay(10); }
Рисуем чертеж индикатора, на котором будем отмечать номер подложки и вывода для каждого сегмента. Подключаем вывод схемы COM к выводу COM1 индикатора, а вывод SEG поочередно подключаем к выводам сегментов. Смотрим, какой сегмент проявился и подписываем номер подложки и вывода на чертеже. Затем подключаемся к подложке COM2, COM3, COM4 и повторяем процедуру.
Результат изысканий показан на рис. 6.

Рис. 6. Чертеж индикатора с разводкой подключения
На этом рисунке первое число обозначает вывод подложки, а второе – вывод сегмента в соответствии с рис. 2. Например, чтобы включить символ S, необходимо приложить противофазное напряжение к выводам COM1 и SEG11. Чтобы включить сегмент G первого знакоместа, надо приложить напряжение к выводам COM3 и SEG2.
Итак, мы выяснили внутреннюю структуру индикатора и назначение выводов. Переходим к считыванию и расшифровке данных с дисплея.
Для удобства дальнейшей работы припаиваем к выводам индикатора временный разъем (рис. 7):

Рис. 7. Отладочное подключение к индикатору
Кстати, странно, что китайцы не сделали на плате выводы для подключения внешнего приемника данных. Причем на плате весов под индикатором установлена микросхема EEPROM для записи настроек. Микроконтроллер весов общается с ней по шине SPI. Что мешало предусмотреть еще и порт UART и посылать в него результат взвешивания, пусть даже на минимальной скорости 9600?
Теперь можно подключить к выводам индикатора логический анализатор и посмотреть, как будут выглядеть сигналы, если синхронизироваться по импульсу низкого уровня линии COM1 (рис. 8):

Рис. 8. Сигналы ЖК индикатора на выводах логического анализатора
Я использовал китайский клон восьмиканального логического анализатора Saleae. На диаграмме хорошо видно, как в каналах 00…03 идут стробы, выделенные анализатором из ступенчатых сигналов подложек. В каналах 04…07 мы видим сигналы на выводах сегментов. Все выглядит именно так, как ожидалось.
Очевидно, что мы должны читать уровни на выводах сегментов по каждому из четырех стробов, а затем программно переводить их в числовое значение веса и информацию о дополнительных символах. Далее эту информацию можно передавать по WiFi в облако или через порт UART/COM непосредственно в компьютер.
Будем инициировать прерывание микроконтроллера по низкому уровню на выводе COM1, а затем при помощи встроенного таймера микроконтроллера отсчитывать интервалы 4 мс и считывать уровни для остальных трех подложек.
Хьюстон, у нас пробле… пшшш… Нужно считывать логические уровни на 12 линиях. Занять 12 из 14 цифровых портов Arduino Uno или Nano – совершенно неприемлемо, а использовать Arduino Mega – расточительно. К счастью, существует расширитель портов на микросхеме PCF8275. Это 16 двунаправленных портов ввода-вывода с открытым коллектором с доступом к ним по шине I2C. То есть, нам достаточно лишь двух стандартных портов микроконтроллера для работы с индикаторами, имеющими до 16 выводов сегментов. Вдобавок, микросхема вообще не требует настройки! Мы просто читаем или пишем данные по заданному адресу шины I2C.
Рекомендую приобрести модуль со встроенным источником питания 3,3 вольта (рис. 9). В этом случае весы и многие другие портативные устройства можно питать непосредственно от модуля.

Рис. 9. Расширитель I2C в 16 портов ввода-вывода
Схема считывателя
Соединяем по схеме (рис. 10) расширитель портов, весы и любую плату Arduino. В моем случае это Arduino Uno R3. Нам нужно сделать так, чтобы по заданному уровню сигнала на линии COM1 возникало прерывание микроконтроллера и начинался цикл считывания данных. Используем для этого встроенный аналоговый компаратор и внутреннее прерывание компаратора. У плат Arduino прямой вход компаратора подключен к выводу D6, а инверсный выход подключен к выводу D7. В обычном режиме это цифровые порты, но если включен аналоговый компаратор, то он забирает эти выводы себе. Потенциометр R1 формирует опорное напряжение на прямом входе компаратора. В моем случае это приблизительно 2,7 В, но для других индикаторов может потребоваться иное напряжение.

Рис. 10. Схема считывателя ЖК индикатора
Вывод индикатора COM1 поступает на инвертирующий вход компаратора. Выводы COM2…COM4 никуда не подключены, потому что после прерывания по импульсу на COM1 остальные интервалы времени для измерений генерируются программно.
Расшифровка и перекодирование символов
Теперь мы полностью готовы работать с программной частью проекта. Но сначала составим наглядную таблицу соответствия битов считываемых данных и сегментов индикатора. Для этого воспользуемся чертежом индикатора из рис. 6 и представим ту же самую информацию в другом виде.

Значения разрядов переменных COM1…COM4
Поскольку у нас четыре подложки и двенадцать сегментов, после четырех циклов считывания мы получим четыре двенадцатиразрядных двоичных числа COM1…COM4, в которых закодирована вся информация с индикатора.
Например, чтобы получить содержимое первого цифрового знакоместа, мы должны последовательно выделить:
разряд 1 числа COM4,
разряд 1 числа COM3,
разряд 1 числа COM2,
разряд 2 числа COM1,
разряд 2 числа COM2,
разряд 2 числа COM3,
разряд 2 числа COM4.
В итоге мы получаем 7-разрядное число, которое описывает первое знакоместо. Например, если первое знакоместо отображает символ 3, ему соответствует двоичное число 1111001, которое описывает логические уровни сегментов A…G. Ну а дальше все просто – по таблице соответствий программно перекодируем 1111001 в символ 3. Аналогично поступаем с остальными знакоместами. Затем составляем из символов знакомест число, отображаемое на индикаторе и передаем его получателю либо в виде строки символов, либо в виде числа с плавающей запятой.
Чтобы узнать, отображается ли дополнительный символ, отдельно проверяем нужный бит. Например, чтобы проверить, не отображается ли символ “минус”, проверяем бит 11 числа COM2.
Скетч считывания данных
В законченном устройстве мы должны выбрать один из двух принципиальных подходов:
- Основную обработку данных вести внутри скетча Arduino и наружу выдавать только полностью готовый результат в виде числа с десятичной точкой, плюс единица измерения (при необходимости).
- Выдавать наружу сырые данные, считанные с индикатора, а всю обработку вести во внешнем устройстве. Например, в приложении компьютера, к которому весы подключены по USB.
Выбор зависит от того, куда и зачем мы передаем данные. Напомню, что прерывание для считывания данных происходит 30-40 раз в секунду. Если это компьютер, то можно ничем себя не ограничивать и гнать по USB сплошной поток сырых данных. Но. с другой стороны, можно эмулировать USB-клавиатуру и передавать только готовое число и символ перевода строки. В этом случае наш прибор сможет автоматически заполнять электронную таблицу или форму базы данных на компьютере. Если мы выгружаем данные в облако по WiFi, то явно незачем передавать в облако одинаковые данные 30 раз в секунду, даже если там их обрабатывает мощный сервер.
В демонстрационном скетче я использовал промежуточный подход. Данные выводятся в последовательный порт в готовом символьном виде, с десятичной точкой и единицей измерений. Но можно выводить и четыре 16-разрядных числа – сырые данные с индикатора. Скетч надежно выполняет главную работу – получение и перекодирование показаний прибора, а дальше вам остается немного доработать его под свою прикладную задачу. Реализация побитового перекодирования настроена под весы, но вы легко переделаете ее под нужное устройство.
#include <Wire.h> // Библиотека для работы с I2C #define ADDR 0x20 // Адрес PCF8575 на шине I2C volatile int flag = 0; uint16_t c1, c2, c3, c4; // Результаты считывания с индикатора uint8_t error, count, hi, lo; byte d1, d2, d3, d4, d5; byte stableFlag, minusFlag, batteryFlag, nonDigitFlag, decimalPoint; String unitSimbol; void setup() { Wire.begin(); Serial.begin(9600); // Порты GPIO0...11 микросхемы PCF8575 как входы uint16_t dataSend = word(B00001111, B11111111); pcf8575_write(dataSend); // Записываем длинное слово в PCF8575 // Настраиваем компаратор pinMode(6, INPUT); // вход компаратора AIN0 pinMode(7, INPUT); // вход компаратора AIN1 // ACD=0 - включить компаратор // ACBG=0 - внутренний источник опорного напряжения отключен // ACIE=1 - разрешить прерывание компаратора // ACIS1=1, ACIS0=0 - прерывание по спаду сигнала на выходе компаратора ACSR = (0 << ACD) | (0 << ACBG) | (1 << ACIE) | (1 << ACIS1) | (0 << ACIS0); // Отладочный выход pinMode(8, OUTPUT); digitalWrite(8, HIGH); } void loop() { if (flag == 1) { ACSR = (0 << ACIE); // Запрещаем прерывание компаратора count++; // Инкремент счетчика измерений delay(1); // Защитная задержка 1 мс //digitalWrite(8, !digitalRead(8)); c1 = ~pcf8575_read(); // Читаем данные при активном COM1 delay(4); //Ждем импульс COM2 //digitalWrite(8, !digitalRead(8)); c2 = ~pcf8575_read(); // Читаем данные при активном COM2 delay(4); //Ждем импульс COM3 //digitalWrite(8, !digitalRead(8)); c3 = ~pcf8575_read(); // Читаем данные при активном COM3 delay(4); //Ждем импульс COM4 //digitalWrite(8, !digitalRead(8)); c4 = ~pcf8575_read(); // Читаем данные при активном COM4 d1 = getCharacterCell(1); // выделяем первое знакоместо d2 = getCharacterCell(2); // выделяем второе знакоместо d3 = getCharacterCell(3); // выделяем третье знакоместо d4 = getCharacterCell(4); // выделяем четвертое знакоместо d5 = getCharacterCell(5); // выделяем пятое знакоместо stableFlag = bitRead(c1, 11); // флаг готовности измерения minusFlag = bitRead(c2, 11); // флаг отрицательного значения batteryFlag = bitRead(c4, 11); // флаг разряда батареи unitSimbol = getUnitSimbol(); // символ единицы измерений decimalPoint = getDecimalPoint(); // позиция десятичной точки if (count < 30) { // Если счетчик измерений меньше 30 // пропускаем вывод в порт } else { count = 0; // Обнуляем счетчик измерений if (stableFlag == 1) { /* // Вывод сырых данных в порт в битовом формате Serial.println(c1,BIN); Serial.println(c2,BIN); Serial.println(c3,BIN); Serial.println(c4,BIN); Serial.println("---------------------"); */ // Выводим на печать результат считывания if (minusFlag == 1) Serial.print("-"); Serial.print(convertDataCell(d5)); if (decimalPoint == 8) Serial.print("."); Serial.print(convertDataCell(d4)); if (decimalPoint == 4) Serial.print("."); Serial.print(convertDataCell(d3)); if (decimalPoint == 2) Serial.print("."); Serial.print(convertDataCell(d2)); if (decimalPoint == 1) Serial.print("."); Serial.print(convertDataCell(d1)); Serial.println(unitSimbol); Serial.println("---------------------"); nonDigitFlag = 0; // обнуляем флаг нецифрового символа } } flag = 0; // Сбрасываем флаг наличия данных на шине // Сбрасываем флаг прерывания, разрешаем прерывание компаратора ACSR = (1 << ACI) | (1 << ACIE); } } // Обработка внутреннего прерывания компаратора ISR (ANALOG_COMP_vect) { flag = 1; // Просто ставим кастомный флаг в 1 } // Процедура записи длинного слова в PCF8575 void pcf8575_write(uint16_t dt) { Wire.beginTransmission(ADDR); Wire.write(lowByte(dt)); Wire.write(highByte(dt)); error = Wire.endTransmission(); if (error == 0) { // Обращение к PCF8575 прошло успешно } else { // Если случилась ошибка записи Serial.println("I2C write error!!!"); } } // Процедура чтения длинного слова из PCF8575 uint16_t pcf8575_read() { Wire.beginTransmission(ADDR); error = Wire.endTransmission(); if (error == 0) { // Обращение к PCF8575 прошло успешно Wire.requestFrom(ADDR, 2); if (Wire.available()) { lo = Wire.read(); hi = Wire.read(); return (word(hi, lo)); // Склеиваем байты в длинное слово } else { // Если данные для чтения недоступны Serial.println("I2C data not available"); } } else { // Если случилась ошибка чтения Serial.println("I2C read error!!!"); } } // выделяем семисегментный код заданного знакоместа i (1...5) int getCharacterCell(int i) { int d = 0; bitWrite(d, 0, bitRead(c3, i * 2)); bitWrite(d, 1, bitRead(c4, i * 2)); bitWrite(d, 2, bitRead(c2, i * 2)); bitWrite(d, 3, bitRead(c1, i * 2)); bitWrite(d, 4, bitRead(c2, i * 2 - 1)); bitWrite(d, 5, bitRead(c3, i * 2 - 1)); bitWrite(d, 6, bitRead(c4, i * 2 - 1)); return d; } // выделяем обозначение единицы измерений String getUnitSimbol() { byte u = B00000000; bitWrite(u, 0, bitRead(c4, 0)); // символ dwt (1) bitWrite(u, 1, bitRead(c3, 0)); // символ ozt (2) bitWrite(u, 2, bitRead(c2, 0)); // символ g (4) bitWrite(u, 3, bitRead(c1, 0)); // символ oz (8) bitWrite(u, 4, bitRead(c1, 1)); // символ gn (16) bitWrite(u, 5, bitRead(c3, 11)); // символ ct (32) switch (u) { case 1: return "dwt"; case 2: return "ozt"; case 4: return "g"; case 8: return "oz"; case 16: return "gn"; case 32: return "ct"; default: return "?"; } } // выделяем указатель десятичной точки byte getDecimalPoint() { byte dp = 0; bitWrite(dp, 0, bitRead(c1, 3)); // dp2 (1) bitWrite(dp, 1, bitRead(c1, 5)); // dp3 (2) bitWrite(dp, 2, bitRead(c1, 7)); // dp4 (4) bitWrite(dp, 3, bitRead(c1, 9)); // dp5 (8) return dp; } String convertDataCell(int d) { String digit = " "; switch (d) { case 0: digit = " "; break; case 126: digit = "0"; break; case 48: digit = "1"; break; case 109: digit = "2"; break; case 121: digit = "3"; break; case 51: digit = "4"; break; case 91: digit = "5"; break; case 95: digit = "6"; break; case 112: digit = "7"; break; case 127: digit = "8"; break; case 123: digit = "9"; break; case 1: nonDigitFlag = 1; digit = "-"; break; case 79: nonDigitFlag = 1; digit = "E"; break; case 55: nonDigitFlag = 1; digit = "H"; break; case 14: nonDigitFlag = 1; digit = "L"; break; case 103: nonDigitFlag = 1; digit = "P"; break; case 5: nonDigitFlag = 1; digit = "r"; break; case 31: nonDigitFlag = 1; digit = "b"; break; case 61: nonDigitFlag = 1; digit = "d"; break; default: nonDigitFlag = 1; digit = "?"; break; } return digit; }
Считывание и перекодировка данных – это довольно долгая задача. Такие задачи не принято обрабатывать внутри подпрограммы прерывания Arduino. Поэтому прерывание просто вскидывает флаг flag = 1; а дальше этот флаг проверяется в главном цикле. Поскольку считывание сигналов с индикатора жестко привязано к его таймингу, нам нужно запретить прерывания на время считывания и перекодировки. Я запрещаю только прерывание компаратора (других у меня в скетче просто нет). После выгрузки готовых данных в порт сбрасываем кастомный флаг, очищаем принудительно флаг прерывания компаратора и разрешаем прерывание компаратора. Отдельно поясню про ловушку для начинающих разработчиков: даже если вы запретили прерывания компаратора, а событие компаратора случилось, то флаг прерывания все равно будет поднят. Просто прерывание не вылезет наружу. Но если флаг поднят, как только вы разрешите прерывание, оно тут же сработает – в произвольный момент времени относительно тайминга индикатора. И вы прочитаете мусор. Поэтому мы сперва принудительно опускаем флаг, потом разрешаем прерывание, и делаем все это в самом конце главного цикла: ACSR = (1 << ACI) | (1 << ACIE);
Поскольку компаратор срабатывает по высокому уровню на подложке, то активный уровень сегментов низкий. Чтобы видимому сегменту соответствовала единица, результаты считывания инвертируем (оператор “тильда” ~).
Флаг готовности измерения, когда весы стабилизировались, выставляется по символу S. Флаг разряда батареи формируется, но в порт не выводится. Предусмотрено перекодирование некоторых буквенных символов, так как весы могут выводить псевдобуквенные строки при неисправности или перегрузке. В таком случае поднимается флаг буквенного значения. Этот флаг может сигнализировать получателю, что передается не вес, а сообщение.
Если символ не удалось перекодировать, возвращается символ “?”. Если данные меняются в момент считывания, то могут проскакивать нечитаемые значения. Поэтому данные выводятся только по флагу готовности.
Данные снимаются около 30 раз в секунду, но для большинства применений незачем выводить их так часто. Поэтому в порт выводится только каждое 30-е считывание, т.е. один раз в секунду. Можно вообще сравнивать текущее значение с предыдущим, и при совпадении не выводить ничего.
В остальном скетч кажется мне достаточно очевидным. Если что-то непонятно, спрашивайте в комментариях. Так выглядит макет, на котором велась отладка:

Рис. 11. Отладочный макет с модулем PCF8575
На маленькой макетной плате установлены многооборотный потенциометр и модуль PCF8575.