Приветствую всех!

Думаю, все мы себе представляем, как работает термопринтер. Но неподвижная пластина и ряд нагревательных элементов не всегда были типичной конструкцией такого устройства.



Итак, в сегодняшней статье разберёмся, как устроен и работает термопринтер старого образца с подвижной головкой. Узнаем, как его подключить к микроконтроллеру и запустить. Традиционно будет много интересного.

❯ Суть такова


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

❯ Обзор оборудования




Так уж получилось, что мне в своё время достались остатки от мониторов пациента компании Criticare (модель мне неизвестна, но, судя по всему, это 506N3). Измерительное оборудование было утрачено, но осталась горсть плат, а также несколько термопринтеров.



Сама плата. Запустить её мне не удалось, при включении она просто выдаёт какую-то ошибку, попутно сообщая, что датчик пульсоксиметра не подключён. Тем не менее, распаивать её я не буду, когда-нибудь мы к ней ещё вернёмся.

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



А вот и термопринтер. Это STP211J-192 от Seiko/Epson. Как ясно из названия, разрешение по горизонтали у него 192 точки. Отчётливо видны два шаговых двигателя, печатающая головка, направляющая, червячный вал.



С обратной стороны ничего интересного.



Слева привод головки. Также тут находится концевой выключатель крайнего её положения.



Справа привод протяжки бумаги.



Из других устройств, где применялись такие термопринтеры, можно вспомнить VeriFone PrintPak. И если в модели 350 стоит самый обычный, то в более старом 300 — именно тот, что у нас. Мною весьма активно ищется такой аппарат, но пока что найти его не вышло.

❯ Что нужно, чтобы управлять таким принтером?


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

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

❯ Моторы


Поскольку шаговые двигатели тут униполярные, для управления ими было решено использовать ULN2804A. Восьми выходов как раз хватит для двух шаговиков, использующихся в принтере.



В даташите на принтер отыскались и последовательности включения двигателя. Так что проблем возникнуть не должно.



Помня об этом, подключаем моторы к ULNке. Выводы 1-8 соединяются с портами контроллера.

❯ ТПГ


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



Сопротивление этих резисторов отличается в зависимости от модели принтера и составляет от четырнадцати до восемнадцати ом.



Итак, схема для управления головкой получается примерно такая.

❯ Контроллер


Для управления решил взять всем известную Arduino — просто из-за пятивольтовых уровней и встроенного USB-UART. У меня нет ответной части к такому шлейфу, поэтому я припаял МГТФ прямо к контактам. Они там очень крупные, можно спокойно подпаяться, не боясь поплавить шлейф.



Собираем всё вместе. Термопринтер просто идеально подошёл по размерам на макетку. На ней же разместились преобразователь питания, две ULNки и плата Arduino. Термоголовка питается от пяти вольт, но брать их от USB нельзя, во время печати ток может составлять больше двух ампер. Всё, можно начинать эксперименты.

❯ Управление моторами


И для начала, конечно, разберёмся с приводами. Тут всё достаточно просто — шаг мотора головки сдвигает её на расстояние одного пикселя, шаг мотора протяжки бумаги прокручивает её на расстояние четверти пикселя. Функции для всего этого получились вот такие:

uint8_t currentPhase = 0;
uint8_t paperCurrentPhase = 0;

void paperStep() {
  switch (paperCurrentPhase) {
    case 2:
      digitalWrite(A0, LOW);
      digitalWrite(A1, LOW);
      digitalWrite(A2, HIGH);
      digitalWrite(A3, HIGH);
      break;
    case 3:
      digitalWrite(A0, LOW);
      digitalWrite(A1, HIGH);
      digitalWrite(A2, HIGH);
      digitalWrite(A3, LOW);
      break;
    case 0:
      digitalWrite(A0, HIGH);
      digitalWrite(A1, HIGH);
      digitalWrite(A2, LOW);
      digitalWrite(A3, LOW);
      break;
    case 1:
      digitalWrite(A0, HIGH);
      digitalWrite(A1, LOW);
      digitalWrite(A2, LOW);
      digitalWrite(A3, HIGH);
      break;
  }
  if (paperCurrentPhase == 3) paperCurrentPhase = 0;
  else paperCurrentPhase++;
}

void headStep(int8_t dir) {
  if (dir == -1) {
    switch (currentPhase) {
      case 1:
        digitalWrite(A4, LOW);
        digitalWrite(A5, LOW);
        digitalWrite(11, HIGH);
        digitalWrite(12, HIGH);
        break;
      case 0:
        digitalWrite(A4, LOW);
        digitalWrite(A5, HIGH);
        digitalWrite(11, HIGH);
        digitalWrite(12, LOW);
        break;
      case 3:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, HIGH);
        digitalWrite(11, LOW);
        digitalWrite(12, LOW);
        break;
      case 2:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, LOW);
        digitalWrite(11, LOW);
        digitalWrite(12, HIGH);
        break;
    }
  }
  else if (dir == 1) {
    switch (currentPhase) {
      case 0:
        digitalWrite(A4, LOW);
        digitalWrite(A5, LOW);
        digitalWrite(11, HIGH);
        digitalWrite(12, HIGH);
        break;
      case 1:
        digitalWrite(A4, LOW);
        digitalWrite(A5, HIGH);
        digitalWrite(11, HIGH);
        digitalWrite(12, LOW);
        break;
      case 2:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, HIGH);
        digitalWrite(11, LOW);
        digitalWrite(12, LOW);
        break;
      case 3:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, LOW);
        digitalWrite(11, LOW);
        digitalWrite(12, HIGH);
        break;
    }
  }
  if (currentPhase == 3) currentPhase = 0;
  else currentPhase++;
}

В отличие от управления головкой, время выполнения тут не слишком критично, поэтому используются «медленные» digitalWrite. Для привода ТПГ также добавлена возможность задания направления.

❯ Инициализация


Отдельно стоит упомянуть про действия после запуска. Сразу после подачи питания МК не знает, где сейчас находится головка. Поэтому необходимо выставить её в нулевое положение — гнать влево, пока она не упрётся в концевой выключатель. Дальше необходимо сделать ещё несколько добавочных шагов, так как датчик срабатывает несколько раньше, чем головка упирается в крайнее положение. Если же ноль уже стоит, выводим головку из него и проверяем, не разомкнулся ли концевик. Если даже после существенного числа шагов он всё равно замкнут, значит, на моторы не подаётся питание или просто нет контакта.
Делается это всё примерно так:

void headInit() {
  if (!digitalRead(10)) headReturn();
  else {
    for (int i = 0; i < 50; i++) {
      headStep(1);
      delay(10);
    }
    if (digitalRead(10)) {
      Serial.println("Head drive error");
      while (1);;
    }
    else headReturn();
  }
}

void headReturn() {
  while (!digitalRead(10)) {
    headStep(-1);
    delay(10);
  }
  for (int i = 0; i < 6; i++) {
    headStep(-1);
    delay(10);
  }
}

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

❯ Управление головкой


Теперь очередь нагревателей. Чтобы задать их состояние, используется следующая функция:

void headControl(uint8_t toHead) {
  PORTD &= B00000011;
  PORTB &= B11111100;
  PORTD |= ((toHead << 2) & B11111100);
  PORTB |= ((toHead >> 6) & B00000011);
}

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

❯ Печать символов


Как известно, головка печатает строку символов за один проход, как в матричном принтере. То есть за раз прожигается вертикальная линия из восьми точек. А это значит, что тот самый шрифт из предыдущего поста про термопринтер подойдёт как нельзя лучше, не придётся разбираться с преобразованием строки символов в восемь горизонтальных линий. Поэтому для печати символа необходимо всего лишь разбить его на пять столбцов, а потом последовательно прожечь их, каждый раз сдвигая головку на один шаг.

Делается это примерно так:

void printChar(char input) {
  uint8_t vertical8dots = 0x00;
  for (int i = 0; i < 5; i++) {
    vertical8dots = pgm_read_byte(&FontTable[input][i]);
    headControl(vertical8dots);
    delay(3);
    headControl(0x00);
    headStep(1);
    delay(10);
  }
  headDriveOff();
}

Задержка перед отключением головки определяет яркость печати. Не стоит пытаться изменить её поднятием напряжения, иначе головка может сдохнуть.

❯ Печать строки


Ну, где символы, там и строка. Делается это всё достаточно просто:

void printString(String toPrinter) {
  int target = 0;
  for (int i = 0; i < 20; i++) {
    headStep(1);
    delay(10);
  }
  if (toPrinter.length() > 18) target = 18;
  else target = toPrinter.length();
  for (int i = 0; i < target; i++) {
    printChar(toPrinter[i]);
    for (int n = 0; n < 3; n++) {
      headStep(1);
      delay(10);
    }
  }
  headReturn();
  headDriveOff();
}

Ничего сложного: прожигаем очередной символ, затем сдвигаем головку на некоторое число пикселей (в моём случае три) и так до конца строки. Затем возвращаем головку на место, и можно проматывать бумагу.

В итоге вся программа получилась такая:

#include "FontTable.h"

uint8_t currentPhase = 0;
uint8_t paperCurrentPhase = 0;

void setup() {
  for (int i = 2; i <= 9; i++) pinMode(i, OUTPUT);
  pinMode(10, INPUT_PULLUP);
  pinMode(11, OUTPUT);
  pinMode(12, OUTPUT);
  for (int i = 14; i <= 19; i++) pinMode(i, OUTPUT);
  Serial.begin(115200);
  headInit();
  headDriveOff();
}

void paperStep() {
  switch (paperCurrentPhase) {
    case 2:
      digitalWrite(A0, LOW);
      digitalWrite(A1, LOW);
      digitalWrite(A2, HIGH);
      digitalWrite(A3, HIGH);
      break;
    case 3:
      digitalWrite(A0, LOW);
      digitalWrite(A1, HIGH);
      digitalWrite(A2, HIGH);
      digitalWrite(A3, LOW);
      break;
    case 0:
      digitalWrite(A0, HIGH);
      digitalWrite(A1, HIGH);
      digitalWrite(A2, LOW);
      digitalWrite(A3, LOW);
      break;
    case 1:
      digitalWrite(A0, HIGH);
      digitalWrite(A1, LOW);
      digitalWrite(A2, LOW);
      digitalWrite(A3, HIGH);
      break;
  }
  if (paperCurrentPhase == 3) paperCurrentPhase = 0;
  else paperCurrentPhase++;
}

void headStep(int8_t dir) {
  if (dir == -1) {
    switch (currentPhase) {
      case 1:
        digitalWrite(A4, LOW);
        digitalWrite(A5, LOW);
        digitalWrite(11, HIGH);
        digitalWrite(12, HIGH);
        break;
      case 0:
        digitalWrite(A4, LOW);
        digitalWrite(A5, HIGH);
        digitalWrite(11, HIGH);
        digitalWrite(12, LOW);
        break;
      case 3:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, HIGH);
        digitalWrite(11, LOW);
        digitalWrite(12, LOW);
        break;
      case 2:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, LOW);
        digitalWrite(11, LOW);
        digitalWrite(12, HIGH);
        break;
    }
  }
  else if (dir == 1) {
    switch (currentPhase) {
      case 0:
        digitalWrite(A4, LOW);
        digitalWrite(A5, LOW);
        digitalWrite(11, HIGH);
        digitalWrite(12, HIGH);
        break;
      case 1:
        digitalWrite(A4, LOW);
        digitalWrite(A5, HIGH);
        digitalWrite(11, HIGH);
        digitalWrite(12, LOW);
        break;
      case 2:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, HIGH);
        digitalWrite(11, LOW);
        digitalWrite(12, LOW);
        break;
      case 3:
        digitalWrite(A4, HIGH);
        digitalWrite(A5, LOW);
        digitalWrite(11, LOW);
        digitalWrite(12, HIGH);
        break;
    }
  }
  if (currentPhase == 3) currentPhase = 0;
  else currentPhase++;
}

void lineFeed() {
  for (int i = 0; i < 48; i++) {
    paperStep();
    delay(10);
  }
  paperDriveOff();
}

void headReturn() {
  while (!digitalRead(10)) {
    headStep(-1);
    delay(10);
  }
  for (int i = 0; i < 6; i++) {
    headStep(-1);
    delay(10);
  }
}

void headInit() {
  if (!digitalRead(10)) headReturn();
  else {
    for (int i = 0; i < 50; i++) {
      headStep(1);
      delay(10);
    }
    if (digitalRead(10)) {
      Serial.println("Head drive error");
      while (1);;
    }
    else headReturn();
  }
}

void headDriveOff() {
  digitalWrite(A4, LOW);
  digitalWrite(A5, LOW);
  digitalWrite(11, LOW);
  digitalWrite(12, LOW);
}

void paperDriveOff() {
  digitalWrite(A0, LOW);
  digitalWrite(A1, LOW);
  digitalWrite(A2, LOW);
  digitalWrite(A3, LOW);
}

void headControl(uint8_t toHead) {
  PORTD &= B00000011;
  PORTB &= B11111100;
  PORTD |= ((toHead << 2) & B11111100);
  PORTB |= ((toHead >> 6) & B00000011);
}

void printChar(char input) {
  uint8_t vertical8dots = 0x00;
  for (int i = 0; i < 5; i++) {
    vertical8dots = pgm_read_byte(&FontTable[input][i]);
    headControl(vertical8dots);
    delay(3);
    headControl(0x00);
    headStep(1);
    delay(10);
  }
  headDriveOff();
}

void printString(String toPrinter) {
  int target = 0;
  for (int i = 0; i < 20; i++) {
    headStep(1);
    delay(10);
  }
  if (toPrinter.length() > 18) target = 18;
  else target = toPrinter.length();
  for (int i = 0; i < target; i++) {
    printChar(toPrinter[i]);
    for (int n = 0; n < 3; n++) {
      headStep(1);
      delay(10);
    }
  }
  headReturn();
  headDriveOff();
}

void loop() {
  String inputString = Serial.readString();

  if (inputString.length() > 0)
  {
    printString(inputString);
    lineFeed();
  }
}



Пробуем что-то напечатать… и оно даже работает! К слову говоря, шрифт очень сильно напоминает тот, что выдаёт матричный принтер. Справа на фото как раз такая распечатка — сходство весьма сильное.

И я даже записал видео с этим:


❯ Двунаправленная печать


А что, если реализовать печать как в матричном принтере — при каждом проходе каретки? Официально этот механизм такое не поддерживает, но ничего не мешает это попробовать.
Для того, чтобы такое реализовать, необходимо поменять алгоритм печати: будем прожигать строку не посимвольно, а всю разом. Для этого создадим массив, куда запишем все символы вместе с пробелами сразу, а потом будем его печатать. Получилось примерно следующее:

void printString(String toPrinter) {
  uint8_t index = 0;
  uint8_t toHead[192];
  for (int i = 0; i < 192; i++) toHead[i] = 0x00;
  int target = 0;
  for (int i = 0; i < 20; i++) {
    headStep(1);
    delay(10);
  }
  if (toPrinter.length() > 18) target = 18;
  else target = toPrinter.length();
  for (int i = 0; i < target; i++) {
    for (int n = 0; n < 5; n++) {
      toHead[index] = pgm_read_byte(&FontTable[toPrinter[i]][n]);
      index++;
    }
    index += 3;
  }
  for (int i = 0; i < 146; i++) {
    headControl(toHead[i]);
    delay(3);
    headControl(0x00);
    headStep(1);
    delay(10);
  }
  headMoveDirection = -1;
  headDriveOff();
}

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

Запускаем и убеждаемся, что всё работает как надо. Как нетрудно догадаться, алгоритм печати в обратном направлении совершенно идентичен:

void printStringReversed(String toPrinter) {
  uint8_t index = 0;
  uint8_t toHead[192];
  for (int i = 0; i < 192; i++) toHead[i] = 0x00;
  int target = 0;
  if (toPrinter.length() > 18) target = 18;
  else target = toPrinter.length();
  for (int i = 0; i < target; i++) {
    for (int n = 0; n < 5; n++) {
      toHead[index] = pgm_read_byte(&FontTable[toPrinter[i]][n]);
      index++;
    }
    index += 3;
  }
    for (int i = 148; i >= 0; i--) {
    headControl(toHead[i]);
    delay(3);
    headControl(0x00);
    headStep(-1);
    delay(10);
  }
  headReturn();
  headDriveOff();
  headMoveDirection = 1;
}

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

void processPrinting(String input) {
  if (headMoveDirection == 1) printString(input);
  else if (headMoveDirection == -1) printStringReversed(input);
  lineFeed();
}

Работает. Но тут я столкнулся уже с чисто конструктивными ограничениями: как бы я ни подкручивал общее число шагов, заставить строки быть точно на одном уровне не вышло. Люфт механизма всё же даёт о себе знать.

Получилось примерно так:


На видео заметен ещё один глюк: при печати в обратном направлении теряются первые два символа. Это косяк не алгоритма печати, а исключительно функции для вывода этой таблицы символов.

❯ Вот как-то так


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

Такие дела.



Возможно, захочется почитать и это: