Пару месяцев назад в очередной раз занесло меня в Акихабару, и конечно же я не мог не заглянуть в свой любимый Akizuki Denshi.
Там обнаружились некие светодиодные панели, светившиеся на демонстрационном стенде приятным малиновым цветом, примерно как полоса прокрутки на ютубе. Ценник был весьма демократичным в силу того, что среди модулей могли быть бракованные. Я скромненько прикупил себе 4 блока, чтобы получить экран 128х16 — хотя теперь уже жалею, что не взял мешок на 36 штук %)
Прочитать даташит, правда, время нашлось уже только в самолёте на пути домой. Как оказалось, это детали от какого-то конструктора бегущей строки из середины нулевых на базе процессора Hitachi H8/3664.
И первый же взгляд на спецификацию заставил поначалу пожалеть о покупке:
Судя по всему, в этих матрицах используется динамическая индикация, которой я так хотел избежать всеми силами. Но что поделать, придётся изобретать.
❯ Сдвиговые регистры и динамическая индикация
Для тех, кто особо дискретную логику не застал: 74HC595 — микросхема сдвигового регистра. Статей о принципах его работы можно найти много в интернете, но по сути это просто последовательно соединённые D-триггеры:
Каждый раз, когда тактовый сигнал переходит в состояние лог. 1, седьмой триггер «забирает» бит, который был в шестом. Шестой забирает оный же у пятого, и так далее. Бит же со входа микросхемы «сохраняется» в нулевом триггере.
Чтобы вся эта беготня не была видна тому, к чему регистр подключен своими выходами, у него есть также «защёлка». Покуда сигнал защёлки находится в состоянии лог. 0, все биты с выходов триггера напрямую проходят на выход. Стоит же защёлку перевести в лог. 1, и эти биты будут зафиксированы на выходе, даже если состояние триггеров с тех пор поменялось.
В конкретно этих модулях сдвиговые регистры установлены «крест-накрест» — один вертикально и два горизонтально, до кучи вертикальный укомплектован инвертером. Поэтому зажигаться будут светодиоды на пересечении установленных бит в этих регистрах.
Даташит приводит пример, как нам предлагается управлять таким модулем:
В данном случае SIN1 — вход вертикального регистра, SIN2 — горизонтального для левой матрицы 16х16, SIN3 — горизонтального для правой матрицы, CLOCK — тактовый сигнал, и LATCH — сигнал защёлки.
После этой последовательности сигналов мы должны получить вот такую картинку:
В принципе, всё сходится. Мы «затолкали» логическую единицу в самый конец вертикального регистра — значит, выбрали верхнюю строку. Затем затолкали 1011 в конец правого горизонтального регистра, и 11 в начало левого — соответствующим образом светодиоды и зажглись.
Если не считать ошибки в том месте, где CLOCK продолжает отстукиваться даже во время открытой защёлки. Ошибка заключается в том, что если вот так отстучать ещё один такт при открытой защёлке, то глазами мы увидим не заданный битами узор, а то, как он съезжает вправо (из-за тактирования горизонтальных регистров) и вверх (из-за тактирования вертикального регистра).
❯ Начинаем с простого
Дописывать драйвер этого дисплея я буду во всё тот же PIS-OS, потому что почему бы и не да.
Очевидно, что простым ногодрыгом через digitalWrite()
и иже с ними управлять таким дисплеем, параллельно делая что-то полезное, будет сложно. Ведь вся описанная выше операция зажигает лишь одну строку — а у нас в дисплее строк аж 16, да и 60 кадров в секунду хотелось бы получить.
То есть минимум 960 раз в секунду нужно генерировать такую последовательность длиной в 64 бита (4 модуля по 16 бит на каждый) — проще говоря, осмысленный сигнал где-то на скорости 61440 бод нам надо выдать. Можно, конечно, пытаться оптимизировать код драйвера, писать в регистры вместо вызова функций смены состояния пинов... но есть вариант попроще, и имя ему SPI.
В ESP32 имеется встроенный SPI контроллер, и у него есть две характерные и важные для нас особенности.
Первая — поддержка QIO, то есть четырёх линий данных. У нас их всего три, поэтому придётся одной пожертвовать.
Вторая — поддержка DMA. То есть мы со стороны процессора можем задать все параметры транзакции, а затем сказать контроллеру: «Посылай байты вот с этого адреса в памяти и до вот этого», и уйти заниматься своими делами. Как он будет эти байты посылать с этого момента нас уже волнует мало.
Поэтому для начала сконфигурируем шину:
spi_bus_config_t bus_cfg = {
.data0_io_num = SIN1_PIN, // SIN1
.data1_io_num = SIN2_PIN, // SIN2
.sclk_io_num = CLOCK_PIN, // CLOCK
.data2_io_num = SIN3_PIN, // SIN3
.data3_io_num = SACRIFICIAL_UNUSE_PIN, // sacrificial pin
.data4_io_num = -1,
.data5_io_num = -1,
.data6_io_num = -1,
.data7_io_num = -1,
.max_transfer_sz = 0,
.flags = SPICOMMON_BUSFLAG_MASTER | SPICOMMON_BUSFLAG_QUAD | SPICOMMON_BUSFLAG_GPIO_PINS
};
esp_err_t res = spi_bus_initialize(
spi,
&bus_cfg,
SPI_DMA_CH_AUTO
);
if(res != ESP_OK) {
ESP_LOGE(LOG_TAG, "SPI Init Error %i: %s", res, esp_err_to_name(res));
return;
} else {
spi_device_interface_config_t dev_cfg = {
.command_bits = 0,
.address_bits = 0,
.dummy_bits = 0,
.mode = 3,
.duty_cycle_pos = 0,
.cs_ena_pretrans = 0,
.cs_ena_posttrans = 0,
.clock_speed_hz = PIXEL_CLOCK_HZ,
.input_delay_ns = 0,
.spics_io_num = LATCH_PIN,
.flags = SPI_DEVICE_NO_DUMMY | SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_BIT_LSBFIRST | SPI_DEVICE_POSITIVE_CS,
.queue_size = 128,
.pre_cb = nullptr,
.post_cb = nullptr
};
res = spi_bus_add_device(spi, &dev_cfg, &hDev);
if(res != ESP_OK) {
ESP_LOGE(LOG_TAG, "SPI Dev Init Error %i: %s", res, esp_err_to_name(res));
return;
}
}
Здесь мы указываем, что в каждом полубайте младший бит будет передаваться через пин SIN1, второй — через SIN2, третий — через SIN3, а старший — на некую ногу МК, которой мы готовы пожертвовать, ведь без указания её инициализация завершится ошибкой (благо, на плате она всё равно разведена в коннектор дисплея, хоть и никуда не подключается). Тактовый сигнал подаём на CLOCK, а сигнал активности передачи на LATCH — таким образом, защёлка будет всегда закрыта, когда данные передаются, и открыта по окончании.
Затем при отрисовке каждого кадр мы формируем массив байтов — размером в два раза больше изначального фреймбуфера — в котором расставляем сигналы пикселей и выбора строки. После чего каждую строку помещаем в очередь на вывод через DMA:
// Вертикально организованный слева направо сверху вниз фреймбуфер со столбцами по 16 пикселей
const uint16_t * columns = (const uint16_t*) strides;
// Цикл по строкам
for(int row = 0; row < rows; row++) {
uint8_t *row_array = &data[row * total_bytes_per_row];
memset(row_array, 0, total_bytes_per_row);
// Цикл по столбцам
for(int col_idx = 0; col_idx < total_bytes_per_row * 2; col_idx++) {
// Находим полубайт, в который попадает текущий столбец
size_t byte_idx = col_idx / 2;
uint8_t nibble_idx = col_idx % 2;
// Находим индекс столбца во фреймбуфере для каждой половинки модуля
int index1 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + (col_idx % (columns_per_panel / 2))) - 1;
int index2 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + ((columns_per_panel / 2) + (col_idx % (columns_per_panel / 2)))) - 1;
uint16_t led2 = columns[index1];
uint16_t led1 = columns[index2];
// Помещаем сигналы зажигания пикселей для левой и правой половинки модуля в первый и второй бит полубайта
row_array[byte_idx] |=
(
((led1 & (1 << row)) == 0 ? 0 : 0b010) |
((led2 & (1 << row)) == 0 ? 0 : 0b100)
) << (nibble_idx ? 4 : 0);
}
// Расставляем сигнал выбора строк для каждой панели в младших битах каждого полубайта
uint8_t byte_no = row / 2;
uint8_t nibble_no = row % 2;
for(int i = 0; i < PANEL_COUNT; i++) {
row_array[byte_no + i * bus_cycles_per_panel / bus_cycles_per_byte] |= 0b001 << (nibble_no == 0 ? 0 : 4);
}
// Кладём посылку в очередь на отправку
_txn->tx_buffer = row_array;
esp_err_t res = spi_device_queue_trans(hDev, _txn, portMAX_DELAY);
if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Txn Error %i: %s", res, esp_err_to_name(res));
}
Как оказалось, из-за задержки между кадрами при таком подходе последняя строка дисплея на глаз светится ощутимо ярче всех прочих. Поэтому пришлось добавить ещё одну посылку пустой строки, чтобы выключить все светодиоды до начала отрисовки следующего кадра. Но в итоге прошивка загрузилась и успешно отрисовала меню на рассыпаных по столу светодиодных панелях:
И казалось бы, всё работает! Значит, можно собрать в корпус.
Питание в этот раз было решено делать встроенное, хоть и пришлось гнездо из блока выкорчевать и приделать к задней панели. Блок питания же закреплён целиком вместе с корпусом — чтобы в случае, если что-то внутри бомбанёт, понизить вероятность возгорания деревянного корпуса.
❯ Но не тут-то было!
Придя на следующее утро в комнату, где эти часы стояли, я заметил, что у них началась дичайшая моргачня. Часть строк то загоралась ярче остальных, то становилась темнее — как на ЭЛТ с уже готовящейся помереть цепью развёртки. На стоп-кадре видео это выглядело вот так:
Причина проста — даже несмотря на то, что мы положили все строки в очередь одной пачкой транзакций, каждая из них отправляется в DMA по отдельности. Как только транзакция закончилась, срабатывает прерывание, процесс драйвера SPI достаёт из очереди следующую и запускает её.
В какой-то момент прерывание срабатывает, а процессор уже обрабатывает какое-то другое, более приоритетное — например, от вайфай-адаптера. Из-за этого между строками получается задержка, и следующая строка загорится чуть позже. Со временем эта задержка накапливается, и в какой-то момент процессор начинает «залипать» не в конце всей очереди, а где-то между транзакциями.
До кучи, мы и так передаём один и тот же буфер, а значит каждый кадр теряем драгоценное процессорное время не только на эти прерывания, но ещё и на бесполезную подготовку абсолютно одинаковых в каждой итерации DMA-дескрипторов в самом драйвере.
❯ Решение «в лоб»
Поначалу, самым очевидным решением показалось запустить бесконечную DMA-транзакцию, а по прерыванию дёргать защёлку между строчками — ведь в бесконечной транзакции мы лишаемся сигнала CS, который дёргается между транзакциями автоматически.
Для этого выкидываем код набора очереди из обработчика кадров, оставляя только подготовку массива данных. В процедуре инициализации же запускаем бесконечный DMA, который этот массив и будет выводить на дисплей.
Так как я не разобрался до конца с настройкой SPI напрямую через регистры, то просто отправляю пустую посылку данных, чтобы драйвер все приготовления сделал для меня, и только после этого настраиваю свою передачу данных:
// Пробная посылка для того чтобы драйвер подготовил нам трансивер
res = spi_device_queue_trans(hDev, &trans, portMAX_DELAY);
if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Txn Error %i: %s", res, esp_err_to_name(res));
else {
spi_dev_t* const spiHw = SPI_LL_GET_HW(spi);
_spi = spiHw;
spi_transaction_t * t = &trans;
// Ждём конец пробной посылки
res = spi_device_get_trans_result(hDev, &t, portMAX_DELAY);
if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Wait Error %i: %s", res, esp_err_to_name(res));
// Создаём дескрипторы DMA для данных
lldesc_s * lldescs = (lldesc_s*) heap_caps_calloc(1, sizeof(lldesc_s) * (rows+1), MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
for(int i = 0; i < rows + 1; i++) {
lldesc_s * d = &lldescs[i];
// Каждый дескриптор отправляет одну строку
d->buf = &data[i * total_bytes_per_row];
d->length = total_bytes_per_row;
d->size = total_bytes_per_row;
d->owner = LLDESC_HW_OWNED;
d->eof = 1; //<- вызывет прерывание EOF на DMA-контроллере после отправки данных под этим дескриптором
// Зацикливаем список дескрипторов по кругу
if(i == rows - 1) {
d->qe.stqe_next = &lldescs[0];
} else {
d->qe.stqe_next = &lldescs[i + 1];
}
}
// т.к. режим SIO/DIO/QIO привязан к транзакции, то нам надо его включить назад самим
spi_line_mode_t lm = {
.cmd_lines = 1,
.addr_lines = 1,
.data_lines = 4,
};
spi_ll_master_set_line_mode(spiHw, lm);
// Записываем в регистр DMA адрес наших дескрипторов
spiHw->dma_out_link.addr = (int)(lldescs) & 0xFFFFF;
spiHw->dma_conf.dma_tx_stop = 0; // Разрешаем работу DMA
// Включаем бесконечный режим
// https://www.esp32.com/viewtopic.php?f=2&t=4011#p18107
// > yes, in SPI DMA mode, SPI will alway transmit and receive
// > data when you set the SPI_DMA_CONTINUE(BIT16) of SPI_DMA_CONF_REG.
spiHw->dma_conf.dma_continue = 1;
spiHw->dma_out_link.start = 1; // Запускаем чтение из памяти по DMA
spiHw->cmd.usr = 1; // Запускаем передачу по SPI
spiHw->dma_int_clr.val = spiHw->dma_int_st.val; // Отключаем все прерывания DMA
spiHw->dma_conf.out_eof_mode = 1; // Режим EOF прерывания — по концу чтения блока из памяти
// Создаём прерывание и включаем его только для события EOF у DMA-контроллера
res = esp_intr_alloc(ETS_SPI3_DMA_INTR_SOURCE, 0, isr, nullptr, nullptr);
if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Intr Alloc Error %i: %s", res, esp_err_to_name(res));
spiHw->dma_int_ena.out_eof = 1;
}
Само прерывание тоже короче некуда:
static IRAM_ATTR void isr(void*) {
_spi->dma_int_clr.out_eof = 1; // <- Очистить флаг прерывания, иначе мы отсюда не выйдем никогда!
// Дёрнуть защёлку
gpio_set_level(_latch, 0);
gpio_set_level(_latch, 1);
}
Эта функция будет вызываться после отправки данных каждой строки и зажигать светодиоды.
Звучит гладко на бумаге, но запускаем и получаем такой себе полуаналоговый спецэффект:
Вокруг изображения то и дело возникают странные искажённые ореолы.
Разгадка проста — защёлка у нас всё ещё завязана на процессор, ибо дёргается программно по прерыванию, а передача данных идёт сама по себе через DMA. Поэтому если прерывание обработалось не сразу, то DMA успевает уже втолкнуть в дисплей часть следующей строки, сместив остатки предыдущей — и тут-то процессор раздупляется, открывает защёлку и зажигает светодиоды! Но показывают они с наибольшей вероятностью уже мусор, лишь отдалённо напоминающий то, что планировалось.
Значит, нужно отвязать процесс выдачи данных на дисплей от процессора совсем. Самое простое — убрать прерывание, а сигнал LATCH завести на оставшийся, неиспользуемый старший бит SPI. Массив удлиняем ещё на 16 байт — по количеству строк, чтобы вставить сигнал защёлки после каждой строкию. Также соответствующим образом исправляем процедуру генерации массива данных:
for(int row = 0; row < rows; row++) {
uint8_t *row_array = &scratch_buffer[row * (total_bytes_per_row + 1)];
memset(row_array, 0, (total_bytes_per_row + 1));
for(int col_idx = 0; col_idx < total_bytes_per_row * 2; col_idx++) {
size_t byte_idx = col_idx / 2;
uint8_t nibble_idx = col_idx % 2;
int index1 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + (col_idx % (columns_per_panel / 2))) - 1;
int index2 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + ((columns_per_panel / 2) + (col_idx % (columns_per_panel / 2)))) - 1;
uint16_t led2 = columns[index1];
uint16_t led1 = columns[index2];
row_array[byte_idx] |=
(
((led1 & (1 << row)) == 0 ? 0 : QIO_BITVAL_SIN2) |
((led2 & (1 << row)) == 0 ? 0 : QIO_BITVAL_SIN3) |
QIO_BITVAL_LATCH // <- держим защёлку закрытой всегда
) << (nibble_idx ? 4 : 0);
}
uint8_t byte_no = row / 2;
uint8_t nibble_no = row % 2;
for(int i = 0; i < PANEL_COUNT; i++) {
row_array[byte_no + i * bus_cycles_per_panel / bus_cycles_per_byte] |= QIO_BITVAL_SIN1 << (nibble_no == 0 ? 0 : 4);
}
// Открываем защёлку только на один цикл шины после передачи строки
row_array[total_bytes_per_row] = (QIO_BITVAL_LATCH << 4);
Заливаем и... выглядит всё почти так же, только ещё и скачет теперь по двум осям, как сломанный старый телевизор. Как раз из-за того, о чём я написал в самом начале статьи — при открытой защёлке тактовый сигнал не останавливается, и видим мы не статичную картинку, а уплывающую по диагонали.
При этом остановить тактовый сигнал мы тоже не можем, ведь SPI-транзакция у нас бесконечная. Но...
❯ Голь на выдумки хитра
По определению данные на шине выставляются заведомо раньше, чем тактовый сигнал изменяет своё состояние — иначе целевое устройство просто не сможет угадать, в какой момент нужно было считать шину данных.
То есть решить проблему можно, подав тактовый сигнал на дисплей через логическое «И» между сигналом защёлки и, собственно, тактовым сигналом шины SPI. В таком случае сигнал защёлки, переходя в состояние лог. 0, будет «глушить» тактовый, и картинка станет нормальной.
На иллюстрации ниже он приведён как CLOCK' = (CLOCK && LATCH).
Однако, паять целую микросхему логики в 14 ног, чтобы взять оттуда по сути два транзистора, мне дюже влом, даром что на печатной плате её и некуда всунуть.
Паять два транзистора навесом мне ещё более влом, ибо дискретные элементы без опыта — ключ к геморрою, когда одно забудешь, второе потеряешь, а третье по температуре само уплывёт, и вообще...
Пара часов курения шитодаты на ESP32 и я натыкаюсь на очень интересный модуль MCPWM — ШИМ-контроллер для управления электроприводами.
Заинтересовало меня в нём наличие входов, отмеченных как FAULT. Ведь если контроллер приводов имеет интерфейс обнаружения неполадок, значит он должен уметь этот привод в случае неполадки максимально быстро остановить...
И правда, дальнейшее чтение даташита показывает, что так оно и есть — в случае неполадки можно принудительно на выход подать лог. 0 или 1:
А ещё у ESP32 очень гибкая матрица маршрутизации GPIO, позволяющая соединять практически любой компонент МК с практически любой ногой на его корпусе. При этом вроде даже и необязательно, чтобы нога была подключена только к одному месту. Так что начинаем творить дичь!
Сначала инициализируем модуль управления приводами со скважностью 100% — то есть на его выходе будет всегда лог. 1:
mcpwm_config_t mcpwm_config = {
.frequency = 500000,
.cmpr_a = 100.0,
.cmpr_b = 100.0,
.duty_mode = MCPWM_DUTY_MODE_0,
.counter_mode = MCPWM_UP_COUNTER,
};
ESP_ERROR_CHECK(mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &mcpwm_config));
Затем настраиваем входы сигналов неполадок так, чтобы при изменении их состояния на лог. 0 «привод» «останавливался» за счёт изменения выхода модуля на лог. 0:
// Два входа со срабатыванием по низкому уровню
ESP_ERROR_CHECK(mcpwm_fault_init(MCPWM_UNIT_0, MCPWM_LOW_LEVEL_TGR, MCPWM_SELECT_F0));
ESP_ERROR_CHECK(mcpwm_fault_init(MCPWM_UNIT_0, MCPWM_LOW_LEVEL_TGR, MCPWM_SELECT_F1));
// Обработка ошибки путём выдачи низкого уровня на выход
ESP_ERROR_CHECK(mcpwm_fault_set_cyc_mode(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_SELECT_F0, MCPWM_ACTION_FORCE_LOW, MCPWM_ACTION_FORCE_LOW));
ESP_ERROR_CHECK(mcpwm_fault_set_cyc_mode(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_SELECT_F1, MCPWM_ACTION_FORCE_LOW, MCPWM_ACTION_FORCE_LOW));
// Отключить прерывания, чтобы не забить весь процессор ими на огромной частоте
mcpwm_ll_intr_disable_all(&MCPWM0);
То есть этот модуль у нас всегда на выходе имеет лог. 1, пока на каком-то из двух входов ошибок не появится 0 — тогда и на выходе появится 0. Ничего не напоминает? :-)
Да, мы только что реализовали нужный нам логический элемент на контроллере приводов. Дальше только присоединить его к нужным нам входам и выходам — и тут мы тоже обойдёмся без паяльника!
Проинициализируем все пины как GPIO через матрицу коммутации и укажем их все как двунаправленные:
PIN_FUNC_SELECT(GPIO_PIN_MUX_REG[CLOCK_PIN], PIN_FUNC_GPIO);
PIN_FUNC_SELECT(GPIO_PIN_MUX_REG[SACRIFICIAL_UNUSE_PIN], PIN_FUNC_GPIO);
PIN_FUNC_SELECT(GPIO_PIN_MUX_REG[LATCH_PIN], PIN_FUNC_GPIO);
gpio_set_direction(CLOCK_PIN, GPIO_MODE_INPUT_OUTPUT);
gpio_set_direction(SACRIFICIAL_UNUSE_PIN, GPIO_MODE_INPUT_OUTPUT);
gpio_set_direction(LATCH_PIN, GPIO_MODE_INPUT_OUTPUT);
Не забыв поправить в конфигурации SPI выход SCK на нашу «жертвенную» ногу, собираем нашу «схему»:
// [SPI]выход CLK --------> жертвенная нога --------> вход FAULT0[MCPWM]
gpio_matrix_out(SACRIFICIAL_UNUSE_PIN, SPI_LL_GET_CLK(spi), false, false);
gpio_matrix_in(SACRIFICIAL_UNUSE_PIN, PWM0_F0_IN_IDX, false);
// [SPI]выход HD(QDATA3) --------> защёлка --------> вход FAULT1[MCPWM]
gpio_matrix_out(LATCH_PIN, SPI_LL_GET_HD(spi), false, false);
gpio_matrix_in(LATCH_PIN, PWM0_F1_IN_IDX, false);
// [MCPWM]выход OUT0A --------> вход тактового сигнала дисплея
gpio_matrix_out(CLOCK_PIN, PWM0_OUT0A_IDX, false, false);
// По итогу получается схема:
//
// [SPI]CLK o------- MCPWM
// +----------------+
// ---| F0 |
// | OUT|---------o DISP_CLOCK = (F0 && F1) = (CLK & LATCH)
// ---| F1 |
// / +----------------+
// [SPI]HD(QD3) o----/
//
Это ж получается, что внутри ESP32 спрятана, в своём роде, почти что настоящая ПЛИС! Очень слабенькая, всего на два вентиля, и занимающая лишние ноги для коммутации до кучи — но захочешь жить и не так извертишься :-)
Тем более в данном случае «жертвенная» нога всё равно разведена на порт дисплея на печатной плате, а на этом конкретном дисплее просто никуда не подключена.
❯ Итог
Прошиваем, запускаем... работает идеально!
Bonus всем дочитавшим: рингтон, прозвучавший в конце видео, можно скачать тут :-)
Вроде бы ничего сверхъестественного тут и не сделано, но сам факт того, что недостающий кусок логики исполняется чисто в железе и без добавления внешних компонентов, вызывает какое-то странное ощущение :-)
Полный код, как обычно, есть у меня на гитхабе. В теории, с таким драйвером можно делать печатные платы для этих часов уже массовыми — в отличие от плазменных экранов от автобусов или даже тех же совместимых с WS0010 ОЛЕГ-дисплеев, простые светодиоды и сдвиговые регистры не покинут наши радиомагазины и склады заводов ещё очень долгие годы.
Ну а дойдут ли у меня руки это сделать вы можете узнать, среди кучи однобитной какофонии на этих самых часах вперемежку с фотками жратвы и Мику, в моём лунном телеграме! :-)
Автор: vladkorotnev