Runtime программирование промышленного робота на RCML

в 11:19, , рубрики: fanuc, RCML, Программирование, Промышленное программирование, Разработка робототехники, робототехника

Runtime программирование промышленного робота на RCML - 1

Под runtime программированием в этой статье понимается процесс создания исполняемой программы для контроллера робота (далее просто робота) на внешнем контроллере. Процесс исполнения роботом созданной программы в таком случае, происходит итерационно, путем передачи ему минимальной исполняемой команды или пакета команд. Другими словами, при runtime программировании, исполняемая программа передаётся роботу порционно, при этом робот не обладает, не хранит и не знает заранее всю исполняемую программу. Такой подход позволяет создать абстрактную параметризованную исполняемую программу, которая формируется внешним устройством «на ходу», т.е. runtime.

Под катом описание и реальный пример того, как работает runtime программирование.

Runtime программирование промышленного робота на RCML - 2Типично программа для робота представляет собой последовательность позиций, в которые должен прийти манипулятор робота. Каждая из этих позиций характеризуются положением TCP (Tool Center Point) – точкой острия инструмента, установленного на манипуляторе. По умолчанию TCP находится в центре фланца робота, см. рисунок ниже, но её положение может быть перенастроено и чаще всего так, что TCP совпадает с острием установленного инструмента на манипуляторе робота. Поэтому обычно при программировании задается положение TCP в пространстве, а положение суставов манипулятора робот определяет сам. Далее в статье будет использоваться термин «положение TCP», или другими словами точка, в которую робот должен «прийти».
Программа для робота также может содержать примитивную управляющую логику (ветвления, циклы), простые математические операции, а также команды по управлению периферией – аналоговыми и цифровыми входами/выходами. В предлагаемом подходе runtime программирования, в качестве внешнего контроллера используется обычный ПК, на котором могут быть использованы мощные средства программирования дающие необходимый уровень абстракции (ООП и прочие парадигмы) и инструменты, обеспечивающие скорость и легкость разработки сложной логики (высокоуровневые языки программирования). На роботе же остается только логика критичная к скорости реакции, для исполнения которой нужна надежность промышленного контроллера, например, оперативная и адекватная реакция на внештатную ситуацию. Управление же периферией, подключенной к роботу, попросту «проксируется» самим роботом на ПК, позволяя ПО с ПК включать или выключать соответствующие сигналы на роботе. Это чем-то похоже на управление «ножками» на Arduino.

Runtime программирование промышленного робота на RCML - 3

Как отмечалось ранее, runtime программирование позволяет передавать роботу программу порционно – частями. Обычно за один раз передается набор состояний выходных сигналов и небольшое число точек или вообще только одна точка. Таким образом траектория перемещений TCP, выполняемая роботом, может строиться динамически и отдельные её части могут принадлежать как разным технологическим процессам, так и даже разным роботам (подключенным к одному внешнему контроллеру), если работает группа роботов, т.е. возникают предпосылки для динамического замещения роботов в технологическом процессе.

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

Еще немного теории и переходим к практике.

Описание существующих способ программирования промышленных роботов

Без учета, вводимого в данной статье подхода runtime программирования, принято выделять два способа программирования промышленных роботов. Офлайн- и онлайн-программирование.

Процесс онлайн программирования происходит при непосредственном взаимодействии программиста с роботом на месте его использования. При помощи пульта управления или физического перемещения осуществляется подвод инструмента (TCP), установленного на фланце робота, к необходимой точке пространства.

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

Процесс офлайн программирования, как понятно из названия, происходит удаленно от робота и его контроллера. Исполняемая программа разрабатывается в какой-либо специализированной среде для программирования промышленных роботов на ПК, а затем загружается в робота целиком. Однако, программные инструменты для такой разработки не входят в базовый комплект поставки робота и являются дополнительными опциями, которые приобретаются отдельно и в своей массе не дешевы.

  • Преимущество офлайн программирования, в том, что робот может быть задействован в производстве и работать, пока разрабатывается программа. Робот нужен только для отладки написанной программы. Нет необходимости выезжать на объект автоматизации и заниматься программированием робота очно.
  • Большим недостатком существующих сред офлайн программирования является их высокая стоимость. Кроме этого, невозможно динамически распределить исполняемую программу между разными роботами.

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

Результат:

ВНИМАНИЕ! Видео не является рекламой, вакансия закрыта. Статья написана после того, как видео потеряло свою актуальность, для того, чтобы продемонстрировать предлагаемый подход программирования.

Написанный текст:

ПРИВЕТ, ЛЮДИ! НАМ НУЖЕН
РАЗРАБОТЧИК.ДЛЯ СОЗДАНИЯ ВЕБ
ИНТЕРФЕЙСА СИСТЕМЫ НАШИХ
ЗНАНИЙ. ТАК МЫ СМОЖЕМ ПЕРЕНЯТЬ
ОТ ВАС ГУМАНОЙДОВ ЗНАНИЯ.

И НАКОНЕЦ-ТО МЫ СМОЖЕМ
ЗАХВАТИТЬ УЛУЧШИТЬ ЭТОТ МИР

ПОДРОБНЕЕ: HTTP://ROBOTCT.COM/HI
ИСКРЕННЕ ВАШ SKYNET =^-^=

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

В качестве примера в спойлере приведен скриншот, с пульта робота, программы рисующей квадрат. В ней всего 5 точек (строки 4-8), каждая точка по сути представляет собой законченное выражение (оператор) и занимает одну строку. Манипулятор обходит каждую из четырех точек и по завершению возвращается в начальную точку.

Скриншот пульта управления с исполняемой программой

Runtime программирование промышленного робота на RCML - 4

Если писать программу подобный образом, то это было бы минимум 1700 операторов — строк кода, по оператору на точку. А что если бы потом потребовалось изменить текст или высоту букв, или расстояние между ними? Править все 1700 точек-строк? Это противоречит духу автоматизации!

Итак, приступим к решению…

Имеем робота FANUC LR Mate 200iD с котроллером R-30i серии B cabinet. У робота предварительно настроена TCP на конце маркера и координатная система рабочего стола, поэтому мы можем отправлять координаты, напрямую не заботясь о преобразовании координат из координатной системы стола в координатную систему робота.

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

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

Чтобы написать текст нам потребуется вызвать последовательность функций, рисующих буквы в такой же последовательности, в которой они (буквы) указаны в тексте. RCML имеет скудный инструментарий для работы со строками, поэтому сделаем внешний скрипт на Python, который будет генерировать программу на RCML – по сути генерировать только последовательность вызовов функций соответствующих последовательности букв.

Весь код доступен в репозитории на GitHub: rct_paint_words

Рассмотрим подробнее выходной файл, исполнение начинается с функции main():

Пример выходного файла на языке RCML

include "chars.rcml"
function main(){
  try {
//Задействование робота
    @fr = robot_fanuc;
    system.echo("Start move programn");
//Предварительная настройка окружения робота, координатная система, нагрузка, скорость
    @fr->set_real_di("speed", SPEED);
    @fr->set_real_di("cnt", CNT);
    @fr->startProgram(UFRAME, UTOOL, PAYLOAD);

    system.echo("preparen");
    @fr->prepare();
    system.echo("start drawn");
//Сгенерированный, на Python, участок
    @fr->draw_r_P(0, 0);
    @fr->draw_P(1, 0);
    @fr->draw_r_I(2, 0);
    @fr->draw_B(3, 0);
    @fr->draw_E(4, 0);
    @fr->draw_T(5, 0);
    @fr->draw_Comm(6, 0);

    @fr->draw_r_L(8, 0);
    @fr->draw_r_Yu(9, 0);
    @fr->draw_r_D(10, 0);
    @fr->draw_r_I(11, 0);
    @fr->draw_Exclamation(12, 0);

    @fr->draw_H(14, 0);
    @fr->draw_A(15, 0);
    @fr->draw_M(16, 0);

    @fr->draw_H(18, 0);
    @fr->draw_r_U(19, 0);
    @fr->draw_r_Je(20, 0);
    @fr->draw_E(21, 0);
    @fr->draw_H(22, 0);

    @fr->draw_P(0, 1);
    @fr->draw_A(1, 1);
    @fr->draw_r_Z(2, 1);
    @fr->draw_P(3, 1);
    @fr->draw_A(4, 1);
    @fr->draw_r_B(5, 1);
    @fr->draw_O(6, 1);
    @fr->draw_T(7, 1);
    @fr->draw_r_Che(8, 1);
    @fr->draw_r_I(9, 1);
    @fr->draw_K(10, 1);
    @fr->draw_Dot(11, 1);
    @fr->draw_r_D(12, 1);
    @fr->draw_r_L(13, 1);
    @fr->draw_r_Ya(14, 1);

    @fr->draw_C(16, 1);
    @fr->draw_O(17, 1);
    @fr->draw_r_Z(18, 1);
    @fr->draw_r_D(19, 1);
    @fr->draw_A(20, 1);
    @fr->draw_H(21, 1);
    @fr->draw_r_I(22, 1);
    @fr->draw_r_Ya(23, 1);

    @fr->draw_B(25, 1);
    @fr->draw_E(26, 1);
    @fr->draw_r_B(27, 1);

    @fr->draw_r_I(0, 2);
    @fr->draw_H(1, 2);
    @fr->draw_T(2, 2);
    @fr->draw_E(3, 2);
    @fr->draw_P(4, 2);
    @fr->draw_r_F(5, 2);
    @fr->draw_E(6, 2);
    @fr->draw_r_Ii(7, 2);
    @fr->draw_C(8, 2);
    @fr->draw_A(9, 2);

    @fr->draw_C(11, 2);
    @fr->draw_r_I(12, 2);
    @fr->draw_C(13, 2);
    @fr->draw_T(14, 2);
    @fr->draw_E(15, 2);
    @fr->draw_M(16, 2);
    @fr->draw_r_y(17, 2);

    @fr->draw_H(19, 2);
    @fr->draw_A(20, 2);
    @fr->draw_r_Sha(21, 2);
    @fr->draw_r_I(22, 2);
    @fr->draw_X(23, 2);

    @fr->draw_r_Z(0, 3);
    @fr->draw_H(1, 3);
    @fr->draw_A(2, 3);
    @fr->draw_H(3, 3);
    @fr->draw_r_I(4, 3);
    @fr->draw_r_Ii(5, 3);
    @fr->draw_Dot(6, 3);

    @fr->draw_T(8, 3);
    @fr->draw_A(9, 3);
    @fr->draw_K(10, 3);

    @fr->draw_M(12, 3);
    @fr->draw_r_y(13, 3);

    @fr->draw_C(15, 3);
    @fr->draw_M(16, 3);
    @fr->draw_O(17, 3);
    @fr->draw_r_Je(18, 3);
    @fr->draw_E(19, 3);
    @fr->draw_M(20, 3);

    @fr->draw_r_P(22, 3);
    @fr->draw_E(23, 3);
    @fr->draw_P(24, 3);
    @fr->draw_E(25, 3);
    @fr->draw_H(26, 3);
    @fr->draw_r_Ya(27, 3);
    @fr->draw_T(28, 3);
    @fr->draw_soft_sign(29, 3);

    @fr->draw_O(0, 4);
    @fr->draw_T(1, 4);

    @fr->draw_B(3, 4);
    @fr->draw_A(4, 4);
    @fr->draw_C(5, 4);

    @fr->draw_r_Ge(7, 4);
    @fr->draw_r_U(8, 4);
    @fr->draw_M(9, 4);
    @fr->draw_A(10, 4);
    @fr->draw_H(11, 4);
    @fr->draw_O(12, 4);
    @fr->draw_r_Ii(13, 4);
    @fr->draw_r_D(14, 4);
    @fr->draw_O(15, 4);
    @fr->draw_B(16, 4);

    @fr->draw_r_Z(18, 4);
    @fr->draw_H(19, 4);
    @fr->draw_A(20, 4);
    @fr->draw_H(21, 4);
    @fr->draw_r_I(22, 4);
    @fr->draw_r_Ya(23, 4);
    @fr->draw_Dot(24, 4);

//Изменение ориентации маркера, чтобы роботу было проще дотянуться до края стола
    @fr->set_real_di("speed", 10); 
    @fr->rotateMarker();
    @fr->set_real_di("speed", SPEED); 

    @fr->draw_r_I(0, 6);

    @fr->draw_H(2, 6);
    @fr->draw_A(3, 6);
    @fr->draw_K(4, 6);
    @fr->draw_O(5, 6);
    @fr->draw_H(6, 6);
    @fr->draw_E(7, 6);
    @fr->draw_r_Ce(8, 6);
    @fr->draw_Minus(9, 6);
    @fr->draw_T(10, 6);
    @fr->draw_O(11, 6);

    @fr->draw_M(13, 6);
    @fr->draw_r_y(14, 6);

    @fr->draw_C(16, 6);
    @fr->draw_M(17, 6);
    @fr->draw_O(18, 6);
    @fr->draw_r_Je(19, 6);
    @fr->draw_E(20, 6);
    @fr->draw_M(21, 6);

    @fr->draw_r_Z(0, 7);
    @fr->draw_A(1, 7);
    @fr->draw_X(2, 7);
    @fr->draw_B(3, 7);
    @fr->draw_A(4, 7);
    @fr->draw_T(5, 7);
    @fr->draw_r_I(6, 7);
    @fr->draw_T(7, 7);
    @fr->draw_soft_sign(8, 7);

    @fr->draw_r_U(10, 7);
    @fr->draw_r_L(11, 7);
    @fr->draw_r_U(12, 7);
    @fr->draw_r_Che(13, 7);
    @fr->draw_r_Sha(14, 7);
    @fr->draw_r_I(15, 7);
    @fr->draw_T(16, 7);
    @fr->draw_soft_sign(17, 7);

    @fr->draw_r_aE(19, 7);
    @fr->draw_T(20, 7);
    @fr->draw_O(21, 7);
    @fr->draw_T(22, 7);

    @fr->draw_M(24, 7);
    @fr->draw_r_I(25, 7);
    @fr->draw_P(26, 7);

    @fr->draw_r_P(0, 9);
    @fr->draw_O(1, 9);
    @fr->draw_r_D(2, 9);
    @fr->draw_P(3, 9);
    @fr->draw_O(4, 9);
    @fr->draw_r_B(5, 9);
    @fr->draw_H(6, 9);
    @fr->draw_E(7, 9);
    @fr->draw_E(8, 9);
    @fr->draw_two_dots(9, 9);

    @fr->draw_H(11, 9);
    @fr->draw_T(12, 9);
    @fr->draw_T(13, 9);
    @fr->draw_P(14, 9);
    @fr->draw_two_dots(15, 9);
    @fr->draw_Slash(16, 9);
    @fr->draw_Slash(17, 9);
    @fr->draw_R(18, 9);
    @fr->draw_O(19, 9);
    @fr->draw_B(20, 9);
    @fr->draw_O(21, 9);
    @fr->draw_T(22, 9);
    @fr->draw_C(23, 9);
    @fr->draw_T(24, 9);
    @fr->draw_Dot(25, 9);
    @fr->draw_C(26, 9);
    @fr->draw_O(27, 9);
    @fr->draw_M(28, 9);
    @fr->draw_Slash(29, 9);
    @fr->draw_H(30, 9);
    @fr->draw_I(31, 9);

    @fr->draw_r_I(2, 10);
    @fr->draw_C(3, 10);
    @fr->draw_K(4, 10);
    @fr->draw_P(5, 10);
    @fr->draw_E(6, 10);
    @fr->draw_H(7, 10);
    @fr->draw_H(8, 10);
    @fr->draw_E(9, 10);

    @fr->draw_B(11, 10);
    @fr->draw_A(12, 10);
    @fr->draw_r_Sha(13, 10);

    @fr->draw_S(15, 10);
    @fr->draw_K(16, 10);
    @fr->draw_Y(17, 10);
    @fr->draw_N(18, 10);
    @fr->draw_E(19, 10);
    @fr->draw_T(20, 10);

    @fr->draw_Equal(22, 10);
    @fr->draw_Roof(23, 10);
    @fr->draw_Minus(24, 10);
    @fr->draw_Roof(25, 10);
    @fr->draw_Equal(26, 10);
// Конец сгенерированного участка
    @fr->stopProgram();
    @fr->go_home();
  } catch(E){
    system.echo("Exception catched!");
    return E;
  }
  return 0;
}

Рассмотрим код отрисовки буквы на примере буквы А:

function robot_fanuc::draw_A(x_cell,y_cell){
  
  //Постановка маркера в точку, координаты точки 5% по Х и 95% по Y в рамке буквы
  robot->setPoint(x_cell, y_cell, 5, 95); 
  //Ведем линию
  robot->movePoint(x_cell, y_cell, 50, 5);
  //Ведем вторую линию
  robot->movePoint(x_cell, y_cell, 95, 95);
  //Получили "крышу" /

  //Переносим маркер с отрывом от стола для отрисовки палочки
  robot->setPoint(x_cell, y_cell, 35, 50);
  //Рисуем палочку
  robot->movePoint(x_cell, y_cell, 65, 50);

  //отрываем маркер от доски для перехода к следующей букве
  robot->marker_up();
}

Функции перемещения маркера в точку с отрывом или без, тоже очень просты:

//Перемещение в точку с отрывом маркера или установка точки для начала рисования
function robot_fanuc::setPoint(x_cell, y_cell, x_percent, y_precent){
  //вычисляем абсолютные координаты
  x = calculate_absolute_coords_x(x_cell, x_percent);
  y = calculate_absolute_coords_y(y_cell, y_precent);
  
  robot->marker_up(); // отрываем маркер от стола
  robot->marker_move(x,y); // перемещаем
  robot->marker_down(); // ставим маркер на стол
}

//Перемещение в точку без отрыва маркера/рисование
function robot_fanuc::movePoint(x_cell, y_cell, x_percent, y_precent){
  x = calculate_absolute_coords_x(x_cell, x_percent);
  y = calculate_absolute_coords_y(y_cell, y_precent);
  
  // тут все понятно :)
  robot->marker_move(x,y);
}

Функции marker_up, marker_down, marker_move содержат лишь код передачи роботу изменившейся части координаты точки TCP (Z или XY)

function robot_fanuc::marker_up(){
  robot->set_real_di("z", SAFE_Z);
  er = robot->sendMoveSignal();
  if (er != 0){
    system.echo("error marker upn");
    throw er;
  }
}

function robot_fanuc::marker_down(){ 
  robot->set_real_di("z", START_Z);
  er = robot->sendMoveSignal();
  if (er != 0){
    system.echo("error marker downn");
    throw er;
  }
}

function robot_fanuc::marker_move(x,y){
  robot->set_real_di("x", x);
  robot->set_real_di("y", y);
  er = robot->sendMoveSignal();
  if (er != 0){
    system.echo("error marker moven");
    throw er;
  }
}

Все константы конфигурации, в том числе размер букв, их количество в строке и пр. были вынесены в отдельный файл chars_config.rcml.

Файл конфигурации chars_config.rcml

define CHAR_HEIGHT_MM 50      // Высота символов в мм
define CHAR_WIDTH_PERCENT 60  // Ширина символов в процентах от высоты

define SAFE_Z -20  // Безопасное положение наконечника маркера по оси z
define START_Z 0   // Рабочее положение наконечника маркера по оси z

// Границы рабочей зоны
define BORDER_Y 120
define BORDER_X 75

// Сигналы ON/OFF
define ON  1
define OFF 0

// Паузы между отправкой сигналов мс 
define _SIGNAL_PAUSE_MILLISEC 50
define _OFF_PAUSE_MILLISEC 200

// Углы Эйлера начального положения маркера – углы ориентации инструмента
define START_W -179.707  // Крен
define START_P -2.500    // Тангаж
define START_R 103.269   // Рыскание

// Углы Эйлера после поворота маркера
define SECOND_W -179.704  // Крен
define SECOND_P -2.514    // Тангаж
define SECOND_R -14.699   // Рыскание

define CHAR_OFFSET_MM 4  // Отступ между буквами

define UFRAME 4     // Номер стола
define UTOOL 2      // Номер инструмента
define PAYLOAD 4    // Номер нагрузки
define SPEED 100    // Скорость
define CNT 0        // Параметр сглаженности перемещения
define ROTATE_SPEED // Скорость при повороте

define HOME_PNS 4  // Номер PNS программы перехода в домашнюю позицию

В итоге суммарно мы получили примерно 300 строк высокоуровневого кода, на проектирование и написание которого ушло не более 2 часов.

Если бы данная задача решалась «в лоб» онлайн программированием по точкам, то на это бы ушло более 9 часов (примерно по 20-25 сек на точку, с учетом того, что точек более 1700 шт.). В этом случае страдания разработчика трудно представить :), особенно когда выяснилось бы, что он забыл про отступы между буквами, или ошибся с высотой букв и текст не влез, и теперь придется начинать всё с начала.

Вывод:

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

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

Как отмечалось такой подход с динамическим построением траектории перемещения создает предпосылки для реализации переключения робота (на событийной основе), как исполнительного ресурса, между несколькими одновременно протекающими задачами.

Однако данный подход следует использовать с осторожностью

В продемонстрированной вариации (с передачей одной точки за раз) runtime подход имеет существенное ограничение – некорректное понимание роботом инструкции сглаживания перемещения (CNT) или её игнорирование, т.к. при передаче всегда одной-текущей точки робот ничего не знает о следующей и не может просчитать сглаженную траекторию обхода текущей точки.

Что же есть CNT?

При перемещении инструмента робота возможно влиять на два параметра:

  • Скорость перемещения — задает скорость перемещения инструмента в мм/сек;
  • Уровень сглаживания (CNT) — позволяет пройти группу точек по траектории с наименьшим расстоянием между крайними точками группы.

Оба эти параметра влияют на конечную получаемую траекторию, что проиллюстрировано на рисунке ниже:

Runtime программирование промышленного робота на RCML - 5

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

В лучшем случае робот просто игнорирует инструкцию CNT (зависит от модели).

Лечится же это передачей 2-х и более точек за раз, где CNT-точка не последняя, однако это повышает сложность программы и нагрузку на программиста.

Надеюсь, статья оказалась вам полезной.

С радостью отвечу на ваши вопросы.

Автор: artyom_n

Источник

Поделиться новостью

* - обязательные к заполнению поля