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

Я должен был разобраться как получить состояние функционирования фискального принтера и его внутренние параметры настройки. Задача давно выполнена, а фискальный принтер был надолго заброшен в дальний угол… Пока в мою голову не пришла идея немного покреативить :D

Такие принтеры позволяют печатать монохромные картинки. Когда я вдоволь наигрался с печатью котиков, эмблем и фотографий коллег, я решил замахнуться на печать длинного гобелена по мотивам сериала, в котором постоянно кого-то убивали со словами «зима близко».

На выходе получился вот такой ролик:


Подробные действия для печати гобелена на языке python под катом ниже.

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

  • скачиваем видео ролик гобелена с youtube
  • создаём длинное монохромное изображение гобелена для печати на фискальном принтере
  • подключаемся к фискальному принтеру
  • печатаем гобелен на фискальном принтере
  • монтируем получившийся видео ролик для публикации в соц сетях

Скачиваем видео ролик гобелена с youtube


Делается очень просто с помощью библиотеки pytube, нужно лишь определиться с индексом видео потока, который собираемся скачать.

Функция для скачивания видео ролика с youtube:
import time, pytube

# оформляем в виде функции для простого многократного использования
def load_bmp_from_video(video_url, filename):
    t1 = time.clock()

    # подготавливаем объект видео ролика
    video = pytube.YouTube(video_url)

    # печатаем доступные потоки для скачивания внутри видеоролика
    streams = video.streams.all()
    for stream in streams: print(stream)

    # сохраняем нужный видео поток в указанный файл,используем индекс 18: 360p mp4
    video.streams.get_by_itag(18).download("./", filename = filename )
    t2 = time.clock()

    # замеряем время скачивания в секундах
    print('download done', t2-t1)

# ролик гобелена будет скачен в файл got.mp4 с разрешением 360x640
load_bmp_from_video(video_url = 'https://www.youtube.com/watch?v=aZV4PclhHeA&', 
                     filename = 'got')

При выполнении строки for stream in streams: print(stream) увидим в консоли перечень всех видео потоков, содержащихся в ролике:

<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">
<Stream: itag="43" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp8.0" acodec="vorbis">
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">
<Stream: itag="137" mime_type="video/mp4" res="1080p" fps="30fps" vcodec="avc1.640028">
<Stream: itag="248" mime_type="video/webm" res="1080p" fps="30fps" vcodec="vp9">
<Stream: itag="136" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.4d401f">
<Stream: itag="247" mime_type="video/webm" res="720p" fps="30fps" vcodec="vp9">
<Stream: itag="135" mime_type="video/mp4" res="480p" fps="30fps" vcodec="avc1.4d401e">
<Stream: itag="244" mime_type="video/webm" res="480p" fps="30fps" vcodec="vp9">
<Stream: itag="134" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.4d401e">
<Stream: itag="243" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp9">
<Stream: itag="133" mime_type="video/mp4" res="240p" fps="30fps" vcodec="avc1.4d4015">
<Stream: itag="242" mime_type="video/webm" res="240p" fps="30fps" vcodec="vp9">
<Stream: itag="160" mime_type="video/mp4" res="144p" fps="30fps" vcodec="avc1.4d400c">
<Stream: itag="278" mime_type="video/webm" res="144p" fps="30fps" vcodec="vp9">
<Stream: itag="140" mime_type="audio/mp4" abr="128kbps" acodec="mp4a.40.2">
<Stream: itag="249" mime_type="audio/webm" abr="50kbps" acodec="opus">
<Stream: itag="250" mime_type="audio/webm" abr="70kbps" acodec="opus">
<Stream: itag="251" mime_type="audio/webm" abr="160kbps" acodec="opus">

Я выбрал поток с идентификатором 18 т.к. он небольшого разрешения — нам всё равно печатать его на чековой ленте, да и качать быстрее))

Создаём длинное монохромное изображение гобелена для печати на фискальном принтере


Для обработки видео нам понадобится известная OpenСV библиотека и Pillow (современный форк PIL) (хотя здесь вместо OpenCV можно было бы использовать утилиту avconv из состава инструмента libav, более подробно о ней в последнем разделе данной статьи). Большое спасибо автору за написание python либы python-opencv, которая представляет собой python wheel, ставиться через PIP и не требует установки самой OpenCV (ура!).

Фискальный принтер может печатать только особые изображения — монохромные bmp файлы фиксированной ширины в 528 пикселей (зато неограниченной длины, хо-хо-хо!). Кроме того, изображение гобелена в видео ролике постоянно движется, поэтому нам нужно аккуратно нарезать кадры так, чтобы получилась одна длинная картинка.

Всё это делает следующая функция:
import os, cv2, numpy as np
from PIL import Image 

# нарезаем скриншоты из видео в изображение для печати на фискальном принтере
def save_frames_from_vide(filename):
    
    # имя файла без расширения понадобится для сохранения финального изображения
    real_filename = filename.rsplit('.', 1)[0]
    
    # для удобства удаляем все старые скрины перед началом
    for file in os.listdir('./'): 
        if file.startswith('frame'):
            os.remove('./' + file)
    
    # нарезаем нужные кадры в список для последующей склейки
    frames_list = []
    vidcap = cv2.VideoCapture(filename)
    try:
        success, frame = vidcap.read()
        count = 1
        while success: # and count < 500: # это для отладки
             # первые скрины на позициях 1 и 100, дальше сцена начинает двигаться
            if count in [1, 100, 30945, 31000] or count % 370 == 0: 
                # калибруем третий и предпоследний скрины (начало и окончание)
                mono_frame = frame
                if count == 370:
                    mono_frame = mono_frame[0:mono_frame.shape[0], 
                                            172:mono_frame.shape[1]]
                if count == 30710:
                    mono_frame = mono_frame[0:mono_frame.shape[0], 
                                            0:mono_frame.shape[1] - 200]
                mono_frame = mono_frame[20:-20, :]
                frames_list.append(mono_frame)
                print('read a new frame: ', success, count)
            success, frame = vidcap.read()
            count += 1
    finally: vidcap.release()

    # склеиваем все нарезанные кадры
    gobelin = np.concatenate((frames_list), axis = 1)
    
    # сохраняем результат - цветной длинный гобелен
    cv2.imwrite('%s.png' % real_filename, gobelin)
    
    # преобразуем картинку для печати на чековой ленте
    image = Image.open('%s.png' % real_filename)
    
    # делаем 1 байт на цвет пикселя чтобы получить монохромный bmp
    fn = lambda x : 255 if x > 135 else 0
    image = image.convert('L').point(fn, mode = '1')
    
    # растягиваем картинку до ширины ленты в 528 пикселей
    coef = 528. / image.height
    new_w = int(image.width * coef)
    new_h = int(image.height * coef)
    image = image.resize((new_w, new_h))
    
    # вращаем гобелен на 270 градусов для печати вертикально и с нужной стороны
    image = image.transpose(Image.ROTATE_270)
    image.save('%s_for_print.bmp' % real_filename)

# сохраняем картинки с видеоролика в файлы 'got.png' и 'got_for_print.bmp'
save_frames_from_vide('got.mp4')


На выходе получаем длинную картинку с изображением всего гобелена, ниже показан лишь фрагмент, оригинал картинки имеет ширину в 55000 пикселей и не проходит по правилам публикации:



А вот такое изображение получается после монохромных преобразований, только без поворота:



Печатаем гобелен на фискальном принтере


В моём распоряжении конкретный фискальный принтер модели Атол fprint-22, но общие правила распространяются и на другие модели фискальных принтеров. Причём мой фискальник довольно древний и ещё не поддерживает новомодные требования ФЗ-54 (напомню, что после вступления в силу этого закона все фискальники обязали отправлять данные через ОФД в налоговую, что повлекло за собой боль и страдания — перепрошивку каждого устройства).

Небольшое отступление о фискальных принтерах. Они относятся к POS устройствам — это всевозможная периферия для нужд торговли, которая подключаются к ПК и интегрируются в единую систему учёта и оплаты. Из подобных известных устройств вы точно видели сканеры штрихкодов и терминалы оплаты банковскими картами. Для всех этих устройств был придуман унифицированный протокол взаимодействия UnifiedPOS.

Короче, это отдельная тема и очень узкий круг специалистов, занимающихся POS устройствами. Ситуацию осложняет тот факт, что большинство этих устройств заточено под функционирование исключительно под ОС Windows через COM объекты — dll файлы с очень плохим документальным описанием функциональных возможностей. Хотя я слышал, про кассовые системы, работающие под FreeBSD, но за время работы с POS устройствами ничего такого не встретил, благо, что от меня не требовалось детальное погружение в мир Retail POS бизнес-процессов…

Поэтому порядок работы с большинством таких девайсов следующий:

  • устанавливается драйвер от производителя
  • настраивается подключение к девайсу через утилиту производителя
  • ищется нужный раздел реестра с нужным устройством
  • ищутся нужные параметры подключения к нему
    (большинство работают по последовательному программному интерфейсу RS-232)
  • осуществляется подключение к устройству через COM объект драйвера производителя
  • осуществляется работа с устройством через API COM объекта
  • осуществляется освобождение COM объекта и физического порта устройства
    (важный момент)

Поскольку в моём распоряжении древний фискальник, то драйвер к нему используется именно 8-ой версии. Сейчас производитель добавил драйвер 10-ой версии, который намного упрощает работу с фискальным принтером через отдельный python wrapper-модуль, что не может не радовать.

Следующий код демонстрирует функцию, которая подключается к фискальному принтеру по выше описанному алгоритму, производит гудок и печатает полученное ранее монохромное изображение гобелена. Нам потребуется установка pywin32.

Код получился довольно длинным и скучным, так что я вынес его под спойлер:
import win32com.client
from _winreg import HKEY_CURRENT_USER, OpenKey, EnumValue

# определяем класс исключения фискальника для упрощения перехвата ошибок
class FiscallError(Exception)

# подключаемся к фискальнику через COM объект драйвера, 
# виртуальный COM порт и печатаем картинку
def fiscal_print(filename):
    
    driver = None
    try:
        # получаем содержимое специального раздела реестра 
        # для фискального принтера фирмы АТОЛ для драйвера версии 8.16.х
        try: 
            hKey = OpenKey(HKEY_CURRENT_USER, 
                           r"Software\Atol\Drivers\8.0\KKM\Devices")
        except Exception as err: 
            raise FiscallError('не удалось прочитать раздел реестра с ' +
                               'параметрами подключения ' +
                               'к устройству Атол FPrint22-ПТК')

        # получаем значение единственной переменной в этом разделе, 
        # в которой хранятся параметры подключения к com порту
        try: 
            device_name,device_connect_params,device_connect_dt=EnumValue(hKey,0)
        except Exception as err: 
            raise FiscallError('не удалось прочитать переменную реестра ' +
                               'с параметрами подключения к драйверу ' +
                               'устройства Атол FPrint22-ПТК')
        
        # разбираем все параметры подключения в словарь
        try: 
            connect_dir = dict([tup.split(u'=') for tup in device_connect_params])
        except Exception as err: 
            raise FiscallError('не удалось распарсить параметры подключения ' +
                               'к драйверу устройства Атол FPrint22-ПТК')
        
        # подключаемся к нужному COM объекту
        try: driver = win32com.client.Dispatch("AddIn.FPrnM8")
        except Exception as err: 
            raise FiscallError('нужный COM объект AddIn.FPrnM8 для устройства ' +
                               'Атол FPrint22-ПТК не найден в ОС, ' +
                               'проверьте наличие и версию драйвера')

        # добавляем логическое устрйоство и параметры подключения к нему 
        add_code = driver.AddDevice()
        if driver.ResultCode != 0: 
            raise FiscallError('ошибка взаимодействия с ККМ Атол FPrint22-ПТК' +
                               ' [код %s] - %s'% (driver.ResultCode,
                                                  driver.ResultDescription))

        # регистрируем параметры подключения к драйверу через COM порт
        driver.Model             = connect_dir['Model']
        driver.PortNumber        = connect_dir['PortNumber']
        driver.UseAccessPassword = connect_dir['UseAccessPassword']
        driver.DefaultPassword   = connect_dir['UseAccessPassword']
        driver.PortNumber        = connect_dir['PortNumber']
        driver.BaudRate          = connect_dir['BaudRate']
        # после этой операции ККМ занимает порт и открывает 
        # по сути физический канал связи по COM порту
        driver.DeviceEnabled     = 1

        # получаем значения основных параметров ККМ из вызова метода GetStatus, 
        # полный перечень всех атрибутов расписан на стр. 61 к руководству по v8.0
        res = driver.GetStatus()
        if driver.ResultCode != 0: 
            raise FiscallError('ошибка взаимодействия с ККМ Атол FPrint22-ПТК ' + 
                               '[код %s] - %s' % (driver.ResultCode, 
                                                  driver.ResultDescription))
        
        ### выполняем действия
        
        # просто гудок, вообще на фискальнике можно сыграть мелодию 
        # звуками разной частоты, но только не во время печати
        driver.Beep()
        
        # максимальная ширина, доступная для печати в пикселях (528)
        print('driver.PixelLineLength:', driver.PixelLineLength) 
        
        # !!! настраиваем параметры печати через атрибуты класса 
        #     (так принято работать с COM объектами)
        driver.Alignment    = 0
        driver.LeftMargin   = 0
        driver.PrintPurpose = 1
        driver.AutoSize     = False
        driver.Scale        = 100
        # указываем имя файла для печати
        driver.FileName     = filename
        
        # собственно печать монохромного bmp файла
        driver.PrintBitmapFromFile
        # делаем небольшой отступ с помощью пустой строки 10 раз
        for i in range(10): driver.PrintString()
        # делаем полную отрезку чековой ленты
        driver.FullCut()
        
        # всё!
    
    except FiscallError as err: 
        raise err
    except Exception as err: 
        raise FiscallError('внутренняя ошибка функции взаимодействия ' + 
                           'с ККМ Атол FPrint22-ПТК - %s' % str(err))
    finally: 
        if driver: driver.DeviceEnabled = 0

fiscal_print('got_for_print.bmp')


На выходе получился такой вот манускрипт, перформанс на «Песнь льда и пламени»:



Принтер под конец совсем взвыл, печатал медленно и тускло, а потом и вовсе допечатал только финальную сцену — такой нагрузки ещё ни один фискальный принтер не видел :D

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

Монтируем получившийся видео ролик для публикации в соц сетях


Для этих целей существует очень полезный и мощный консольный инструмент, заменяющий целый видео редактор — libav. В его составе есть утилиты avconf и ffmpeg для работы с видео и аудио файлами. Честно говоря, для меня этот инструмент стал настоящим открытием, всем рекомендую!

Основная идея монтажа:

  • вырезать из отснятого на смартфон ролика часть в начале и часть в конце
    (подогнать под 3 проигрыша mp3 файла с 8bit музыкой)
  • записать 3 проигрыша аудио файла в один файл
  • наложить видео файл и аудио файл в новый видео файл
  • сконвертировать видео файл из формата mov в формат mp4
    (мой смартфон снимает ролики с расширением mov

Для этих целей я написал скрипт для запуска в командой строке, который может выполняться как bash в linux, так и bat в win (различия указаны в комментариях скрипта):
# обрежем ролик в начале
avconv -ss 00:00:10 -i got_print.mov -t 00:06:00 -c:v copy got_print_tmp.mov

# подготовим файл для зацикливания музыки (под win)
(echo file 'got_8bit.mp3' & echo file 'got_8bit.mp3' & echo file 'got_8bit.mp3') > list.txt

# подготовим файл для зацикливания музыки (под linux)
# cat list.txt
# file 'got_8bit.mp3'
# file 'got_8bit.mp3'
# file 'got_8bit.mp3'

# зациклим музыкальный файл в 3 раза
ffmpeg -f concat -i list.txt -acodec copy got_8bit_3.mp3

# наложим видео и звук в один финальный ролик
avconv -i got_print_tmp.mov -i got_8bit_3.mp3 -c copy got_print_final.mov

# конвертируем выходной видео файл в формат mp4
avconv -i got_print_final.mov -c:v libx264 got_print_final.mp4

# удалим лишние файлы (windows)
del got_8bit_3.mp3
del got_print_tmp.mov

# удалим лишние файлы (linux)
# rm got_8bit_3.mp3
# rm got_print_tmp.mov


Вот и всё, ролик создан:


P.S.: моя первая статья на Хабре, планировал написать короткую статью для начала, сокращал как мог) надеюсь, что чтение было приятным, а результат моей работы — интересным)

Комментарии (21)


  1. tvr
    30.08.2019 18:21
    +1

    Вот это я понимаю, троллейбус из буханки.
    Тем, кто будет спрашивать — «А зачем вот это вот всё?» — отвечу за автора — «А потому, что может!».
    Принтер выжил или его таки забрали белые ходоки?


    1. viking_unet Автор
      30.08.2019 18:25

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


  1. 402d
    30.08.2019 20:01

    viking_unet, А какая суммарная длина чека получилась?
    Над атоллами не издевался. А на простой esc/pos скриншот веб страницы в 10 метров
    одной картинкой под андроидом получается послать.
    Вашу наверное мое приложение для атоллов не переварит. Просто не хватит памяти, чтобы сделать дизеринг


    1. viking_unet Автор
      30.08.2019 20:07

      Длина файла для печати составила 55к пикселей, я писал в статье) Файл получился около 25 мб, я тоже опасался, что не переварит буфер. Но в итоге подвела только печатающая головка — она тупо перегрелась, расширился пластик и бумага стала выходить неохотно. Почти весь гобелен был напечатан, возможно где-то 45к пикселей из-за проблем при печати


      1. 402d
        31.08.2019 20:18

        плотность у атоллов имхо тоже 8 точек на мм.
        56 / 8 = 7 (т.е. чуть меньше 7 метров.) Правильно?
        Буфер в принтере не важен. Атолловские драйвера
        посылают картинку по одной строчке.
        Там замороченный протокол. Строб начала кадра. команда + данные. Строб конца кадра
        Переход драйвера в ожидание ответа об исполнении.
        И так далее.
        Так что все ограничения по объему на уровне операционной системы.


        1. icoz
          01.09.2019 08:09

          я бы, на самом деле, разобрал протокол и сам бы слал по строчке в принтер, давая задержки иногда, чтобы головка остывала.


          1. 402d
            01.09.2019 08:59

            Протокол описан.
            Yfghbvth в документе «Описание протокола ККТ v2.4» посмотрите страницу 19.
            Дана блок-схема.
            Но смысла переписывать драйвера для вашего желания нет. Можно просто порезать
            на уровне своей программы картинку на полосы и посылать их в нужном темпе.


            1. icoz
              01.09.2019 10:40

              У меня был опыт, когда есть кривые дрова под виндоус, а надо под линой печатать. Описания протокола не было.


  1. 402d
    31.08.2019 20:28

    Теперь еще остается добавить музычку ;)
    youtu.be/xBWnS7BH9JQ?t=11060


    1. viking_unet Автор
      31.08.2019 20:42

      Музыку пробибикать на принтере — не проблема, только он не умеет одновременно печатать и звуки издавать, так что процесс печати затянется на 8 сезонов))


  1. AVX
    31.08.2019 21:27

    Очень интересно, спасибо!
    Про атоллы слышал, что временами капризны, в том числе при прошивке. А вот довелось поработать в плане прошивки и настройки с РИТЕЙЛ-01Ф — там и документация некоторая есть, в том числе и команды для отправки какого-либо текста, и есть тест драйвера (утилита такая) — в ней есть возможность печатать текст и картинки. Подозреваю, что можно аналогично сделать.
    По поводу скрипта под win/bash — его можно оформить в виде ОДНОГО и того же файла, который без изменений можно запустить и в винде (bat, powershell) и под линуксом (bash). Правда, это уже к теме не относится, и вызовет у читателей больше вопросов (что за хрень в скрипте??? Можно ли это вообще запускать?… и том у подобные).


    1. viking_unet Автор
      31.08.2019 21:46

      РИТЕЙЛ-01Ф, забавно, у нас с ним была как раз самая большая проблема: пришлось генерировать отдельный враппер для питона чуть ли не с сишными вызовами. Мне понравилась Искра Прим, у них там отдельный хорошо описаный драйвер Azimuth.dll — просто скопировал и работай) утилиты по настройке и тестам команд есть почти у всех фискальников, в том числе у Атола, но для него напечатать картинку удалось только программным способом, утилита ни в какую не печатала изображения


  1. cellmon
    31.08.2019 22:31

    Класс. Респект Автору. Вот это идеи для новой жизни!
    А Теперь представьте, если б было реально этот гобелен, отослать прямо в ФНС (налоговую)… интересно надолго б они exception поймали? :):):)


    1. 402d
      31.08.2019 23:15

      отослать в налоговую не получится. в формате обмена слава богу нет такого функционала.
      а то 10 таких фоток и под замену фнку.
      В паспорте ФН не написано, сколько чеков он может вместить, но народные умельцы разобрали один из первых ФН и нашли внутри модуль памяти на 256 Мб.


      1. Mur81
        01.09.2019 13:57

        Мне кажется чеки из ФН спустя какое-то время удаляются. Т.к. после пробития чек из ФН можно вытащить целиком, но спустя какое-то время (какое не знаю) это сделать уже не получается.


        1. vasfed
          01.09.2019 22:02

          Не целиком и не сразу — в законодательстве (приложения к приказам ФНС об ФФД) где описаны форматы данных есть ещё и инфа какие поля чеков сколько хранить. Там выходит что 30 дней надо хранить все, а потом только основное. Кстати похоже ровно отсюда же берётся и блокировка кассы, если данные не аплоадились 30 дней — чтобы не было попыток отослать неполные версии


  1. inwardik
    01.09.2019 15:41

    Функции load_bmp_from_video в определении надо ж еще filename принять.
    В остальном — все работает. Спасибо, было интересно!


    1. viking_unet Автор
      01.09.2019 18:18

      Поправил, спасибо


  1. composer_ondo
    31.08.2019 23:37

    Бессмысленно, беспощадно, но молодец) Потратил кучу энергии непонятно зачем, Но хочется так же)


  1. lnking
    01.09.2019 12:19

    Не знаю как сейчас, а в 2006-2007, примерно, у Штриха была отличная документация про низкий уровень для работы с фр. Благодаря ей тогда же и написали для себя библиотеку, чтобы кассовый софт работал на Linux вместо win + 1C 7.7


    1. vasfed
      01.09.2019 22:07

      У Атола тоже вполне сносный драйвер к актуальным железкам на базе QT и libusb, жалко без исходников, но в комплекте даже под arm сборка, касса замечательно работает будучи прицепленной к raspberry