Привет, Хабровчане! Этот цикл (надеюсь) статей будет посвящён моему пути в создании своего собственного решения по струйной печати. Это будет что-то вроде блога или дневника разработчика в котором постараюсь изучить как же всё таки работает печатающая головка у принтера и как ей можно управлять с помощью микроконтроллера. А также нас ждёт интригующий ответ на вопрос: «Если ли место DIY и OpenSourse в мире струйной печати».
Пролог
Когда-то я решил завести себе хобби — печатать книги на домашнем принтере, а помогал мне в этом уже немолодой HP Deskjet 2630 с приколхоженной подключённой к нему СНПЧ дабы не заправлять картриджи каждый день.
Всем известны типичные проблемы струйных принтеров:
-
Остановиться посреди печати с ошибкой
-
Не видеть картриджи, в том числе новые
-
Засорение дюз и многое другое
Думаю, каждый сможет пополнить парой-тройкой десятков проблем. В какой-то момент терпение лопается и на смену старому принтеру приходит новый вместе с ощутимой потерей веса кошельком, либо мы познаём дзен и учимся жить с тем что есть.
Будучи инженером-робототехником по образованию и программистом по профессии я был не готов мириться с таким положением дел и решил заняться поисками способов кастомизировать свой принтер. Я надеялся найти огромное количество статей в духе «перепрошивка принтера», «open source прошивка для принтера», «струйный принтер на Arduino», «DIY принтер» и т.д. .
Однако, реальность оказалась жестока и я не смог найти ни одного подходящего решения. Были умельцы, которые модифицировали принтер в планшетный для печати на тортах или текстолите, кто-то на основе совсем уж старой печатающей головки делал что-то вроде CNC. Это были как и достойные, так и колхозные работы, но они либо не меняли ПО и железо, либо принтер переставал быть принтером.
Моя же цель заключалась в том чтобы полностью поменять закрытое и неуправляемое ПО домашнего принтера на открытое и гибкое.
§1. Становление идеи
Наткнувшись на статью про Взлом цветного картриджа HP (и основана на блоге) в мою голову закралась мысль, что это можно попробовать воспроизвести. После я нашёл статью на Hackaday где автор, использую начинания предыдущего автора для своего проекта и адаптацию для Arduino от другого автора. Это придало мне некоторую уверенность в что можно попытаться создать свой принтер на базе этих решений.
Безуспешно пытаясь хотя бы запустить код на Arduino UNO (ESP32 просто не было под рукой), я пришёл к выводу что всё будет не так просто и мне придётся начинать свой собственный проект практически с нуля.
§2. Бездна анализа, планирования и ТЗ
Покрутив в голове мысль о собственном проекте, я сформировал для него первые требования к тому что я хочу получить:
-
Устройство должно подключаться к ПК и определяться им как принтер
-
Устройство должно управлять картриджами HP123 (такие используются моим принтером и авторами упомянутых выше статей)
-
У устройства должен быть минимальный HMI — небольшой дисплей для вывода информации и несколько кнопок для управления
И я стал думать над базой. Если вышеупомянутые проекты несли скорее исследовательский и развлекательный характер, то мне хотелось создать более комплексное решение, так что я отказался от использования ESP32 и Arduino. Мой взгляд пал на давно пылящуюся в закромах NUCLEO-411RE в качестве основы — 512 Кбайт Flash и 128 Кбайт RAM дают пространство для разворота, а DMA и 72 МГц максимальной частоты поможет меньше задумываться о производительности, не говоря уже об огромном количестве возможной периферии.
Теперь можно добавить чуть больше конкретики для начала прототипирования:
-
NUCLEO-411RE как основа
-
Проект должен быть открытым для желающих повторить, что-то улучшить или же создать что-то своё на базе. Плохо знаком с лицензиями, но, думаю Apache 2.0 подходит
-
Необходимо оставить, по крайней мере 10-15 пинов ШИМ чтобы управлять одной печатающей головкой (я ещё не разобрался с подробностями управления, пока будем держать в голове такие числа)
-
Используем просто OLED дисплей 128×64 для вывода информации. Он работает по I2C и уже есть библиотеки для работы с ним
-
Больше мосфетов богу мосфетов. Пины печатающей головки подключаются к нагревательному элементу (немного подробнее описано тут), а значит могут потреблять значительный ток (где-то встречал значение до 2.5А). Таким образом, как было отмечено в статьях ранее, банального преобразователя уровня недостаточно, необходимо хорошее усиление
-
В качестве основного ввода можно воспользоваться стиком для Arduino и свести 5 кнопок в 3 пина + подключим модуль для расширения GPIO по I2C дабы иметь больше кнопок (включение, отмена, информация и т.д.) и не трогать ценные пины контроллера
-
Будет хорошей идеей подключить SD карту, её можно будет использовать в качестве источника для задания печати, писать логи и (в крайнем случае) хранить ресурсы для дисплея, возможно появятся ещё идеи. Можно было бы воспользоваться модулем с SDIO, но у меня уже имеется SPI модуль — воспользуемся тем что есть
-
Нужно будет перемещать печатающую головку и бумагу. Самое логичное решение — CNC Shield для Arduino + набор шаговых двигателей. Просто и всем знакомо по 3D принтерам. Будем считать шаги для определения положения и концевики для крайних положений
-
Необходимо хранить профили коррекции цвета, возможно для этого подойдёт модуль EEPROM, который также можно подключить по I2C
-
Логгер. Вывод в консоль через UART и в файл жизненно необходим
-
Разобраться с подключением контроллера как принтера
-
Предстоит много коммуникации и периферии так что можно воспользоваться FreeRTOS. Давно хотел
потыкать её палкойпоработать с ней, но не мог придумать подходящей задачи -
Используем DMA везде где это возможно: SPI, I2C, UART
-
Простой обработчик событий. Да FreeRTOS имеет механизм сообщений, но он кажется слишком громоздким для простого события нажатия кнопки
Тем самым вырисовываются первые функциональные модули, которые могут работать в отдельных потоках:
-
модуль для работы с дисплеем,
-
модуль для «прямой» работы с пинами ввода-вывода. Вынесем сюда обработку GPIO, в том числе ADC и работу с I2C расширением
-
модуль логирования — без комментариев
-
простой обработчик событий. Да FreeRTOS имеет механизм сообщений, но он кажется слишком громоздким для простого события нажатия кнопки
-
модуль обработки событий. Для начала ничего сложного — вызываем событие с параметром в виде enum’а, а модуль будет вызывать подписанные обработчики (они сами решат какие события обрабатывают)
-
модуль для управления шаговыми двигателями
-
модуль для управления печатающей головкой
-
модуль для контроля печати. Будем контролировать прогресс печати, преобразовывать задание печати для управления моторами и печатающей головкой (возможно объединение с управлением моторами и печатающей головкой) + контроль уровня чернил (датчик уровня для СНПЧ и счётчик со сбросом для картриджей)
Таким образом у меня уже есть что-то похожее на план от которого можно отталкиваться при разработке ПО.
§3. Проблемы и вопросы
Начнём с главной проблемы: все печатающие головки проприетарные и на них нет документации в открытом доступе. Так что мне пригодятся начиная связанные с блогом.
Исходя из этой проблемы вытекает следующая: в сети полно статей как работать с принтером, в основном про принт-серверы, но практически ни одной статьи как это подключение устроено с другой стороны. C похожей проблемой столкнулся автор этой статьи. Несмотря на наличие спецификации для USB Printer Class мне так и не удалось найти ни одной реализации, даже пример реализации от STM найти удалось далеко не сразу.
В связи с этим на поверхности появляется уже появляются, например:
-
вагон протоколов LPR/LPD, RAW Printing, IPP, SNMP и т.д.
-
можно ли расширять эти протоколы своими командами? Например, можно написать свою программу для ПК, которая будет способна отправлять команды на прочистку дюз, калибровку и много другое
-
В каком виде приходит задание на печать? Какие преобразования должны происходить? В конце концов это должен быть набор пикселей в CMYK, который должен быть преобразован в управляющие сигналы для печатающей головки
-
Отличается ли задание на печать текста от изображения?
Ответы на них можно будет получить при дальнейшем изучении в процессе работы над конкретным функционалом.
§4. А где писать код? Или Cmake для STM32
Вопрос в заголовке кажется глупым и очевидным, но тем ни менее настройка среды для разработки довольно важным моментом в работе. Кто-то поспорят что я всё усложняю, но кто-то найдёт мои следующие рассуждения весьма занимательными.
Для STM32 наиболее простым решением будет ST32 CubeIDE. В него уже интегрирован CubeMX для конфигурации микроконтроллера, а так как у меня дефицит опыта работы с STM32, то это идеальный вариант. Однако, редактор кода основан на Eclipse, который не отличается своим удобством и дружелюбностью. Я провёл достаточно времени в Visual Studio, Visual Studio Code, Android Studio и прочих IDE от JetBrains и ни один переход в другую IDE не вызывает такой боли как переход в Eclipse.
Самой удобной для меня всегда оказывался VS Code, будь то Embeddeed или Web проекты. Однако нельзя просто так взять и создать проект для STM32 в VS Code, а устанавливать и настраивать вагон расширений, который работает с CubeIDE зависимостями просто не хочется.
Многие STM32 разработчики привыкли к CubeIDE, так что я обязан сохранить работу с ней если хочу чтобы в будущем проект мог поддерживаться другими людьми, но при этом для свой собственной эффективности мне нужен VS Code. Выход из этой ситуации простой — использовать CMake.
Идея заключается в том чтобы положить рядом с ioc-файлом CMakeLists.txt, благо, CubeIDE тоже умеет их создавать (теряя при этом ioc-файл), так что мы воспользуемся одним из таких сгенерированных CMakeLists.txt и немного доработаем:
CmakeLists.txt
############################################################################################################################# # file: CMakeLists.txt # brief: Template "CMakeLists.txt" for building of executables and static libraries. # # usage: Edit "VARIABLES"-section to suit project requirements. # For debug build: # cmake -DCMAKE_TOOLCHAIN_FILE=cubeide-gcc.cmake -S ./ -B Debug -G"Unix Makefiles" -DCMAKE_BUILD_TYPE=Debug # make -C Debug VERBOSE=1 -j # For release build: # cmake -DCMAKE_TOOLCHAIN_FILE=cubeide-gcc.cmake -S ./ -B Release -G"Unix Makefiles" -DCMAKE_BUILD_TYPE=Release # make -C Release VERBOSE=1 -j ############################################################################################################################# set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_VERSION 1) cmake_minimum_required(VERSION 3.20) ###################### CONSTANTS ###################################### set (PROJECT_TYPE_EXECUTABLE "exe") set (PROJECT_TYPE_STATIC_LIBRARY "static-lib") set (MCPU_CORTEX_M0 "-mcpu=cortex-m0") set (MCPU_CORTEX_M0PLUS "-mcpu=cortex-m0plus") set (MCPU_CORTEX_M3 "-mcpu=cortex-m3") set (MCPU_CORTEX_M4 "-mcpu=cortex-m4") set (MCPU_CORTEX_M7 "-mcpu=cortex-m7") set (MCPU_CORTEX_M33 "-mcpu=cortex-m33") set (MCPU_CORTEX_M55 "-mcpu=cortex-m55") set (MCPU_CORTEX_M85 "-mcpu=cortex-m85") set (MFPU_FPV4_SP_D16 "-mfpu=fpv4-sp-d16") set (MFPU_FPV4 "-mfpu=vfpv4") set (MFPU_FPV5_D16 "-mfpu=fpv5-d16") set (RUNTIME_LIBRARY_REDUCED_C "--specs=nano.specs") set (RUNTIME_LIBRARY_STD_C "") set (RUNTIME_LIBRARY_SYSCALLS_MINIMAL "--specs=nosys.specs") set (RUNTIME_LIBRARY_SYSCALLS_NONE "") set (MFLOAT_ABI_SOFTWARE "-mfloat-abi=soft") set (MFLOAT_ABI_HARDWARE "-mfloat-abi=hard") set (MFLOAT_ABI_MIX "-mfloat-abi=softfp") ####################################################################### ###################### VARIABLES ###################################### set (PROJECT_NAME "test_F411RE") set (PROJECT_TYPE "exe") set (LINKER_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/STM32F411RETX_FLASH.ld") set (MCPU "-mcpu=Cortex-M4") set (MFLOAT_ABI "") set (RUNTIME_LIBRARY "--specs=nano.specs") set (RUNTIME_LIBRARY_SYSCALLS "--specs=nosys.specs") file(GLOB_RECURSE SOURCES Drivers/STM32F4xx_HAL_Driver/Src/*.* Core/Src/*.* ) set (PROJECT_SOURCES # LIST SOURCE FILES HERE Core/Startup/startup_stm32f411retx.s ${SOURCES} ) set (PROJECT_DEFINES # LIST COMPILER DEFINITIONS HERE STM32F411xE ) set (PROJECT_INCLUDES # LIST INCLUDE DIRECTORIES HERE Core/Inc Drivers/STM32F4xx_HAL_Driver/Inc Drivers/CMSIS/Include Drivers/CMSIS/Device/ST/STM32F4xx/Include ) # specify cross-compilers and tools set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g++) set(CMAKE_ASM_COMPILER arm-none-eabi-gcc) set(CMAKE_AR arm-none-eabi-ar) set(CMAKE_OBJCOPY arm-none-eabi-objcopy) set(CMAKE_OBJDUMP arm-none-eabi-objdump) set(SIZE arm-none-eabi-size) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) ############ MODIFY ACCORDING TO REQUIREMENTS) ######################## ####################################################################### ################## PROJECT SETUP ###################################### project(${PROJECT_NAME}) enable_language(ASM) add_executable(${PROJECT_NAME} ${PROJECT_SOURCES} ${GLOB_RECURSE}) add_custom_command(TARGET ${CMAKE_PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${CMAKE_PROJECT_NAME}>) add_compile_definitions (${PROJECT_DEFINES}) include_directories (${PROJECT_INCLUDES}) set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type") set (CMAKE_EXECUTABLE_SUFFIX ".elf") set (CMAKE_STATIC_LIBRARY_SUFFIX ".a") set (CMAKE_C_FLAGS "${MCPU} -std=gnu11 ${MFPU} ${MFLOAT_ABI} ${RUNTIME_LIBRARY} -mthumb -Wall -Werror") set (CMAKE_EXE_LINKER_FLAGS "-T${LINKER_SCRIPT} ${RUNTIME_LIBRARY_SYSCALLS} -Wl,-Map=test_F411RE.map -Wl,--gc-sections -static -Wl,--start-group -lc -lm -Wl,--end-group") set (CMAKE_ASM_FLAGS "${CMAKE_C_FLAGS} -x assembler-with-cpp")
Используем PROJECT_SOURCES для всех .с файлов, PROJECT_INCLUDES для всех директорий с .h файлами. В целом, он выглядит как классический CMakeLists.txt для исполняемого файла с некоторыми дополнениями от CubeIDE, такими как константы:
###################### CONSTANTS ###################################### set (PROJECT_TYPE_EXECUTABLE "exe") set (PROJECT_TYPE_STATIC_LIBRARY "static-lib") set (MCPU_CORTEX_M0 "-mcpu=cortex-m0") set (MCPU_CORTEX_M0PLUS "-mcpu=cortex-m0plus") set (MCPU_CORTEX_M3 "-mcpu=cortex-m3") set (MCPU_CORTEX_M4 "-mcpu=cortex-m4") set (MCPU_CORTEX_M7 "-mcpu=cortex-m7") set (MCPU_CORTEX_M33 "-mcpu=cortex-m33") set (MCPU_CORTEX_M55 "-mcpu=cortex-m55") set (MCPU_CORTEX_M85 "-mcpu=cortex-m85") set (MFPU_FPV4_SP_D16 "-mfpu=fpv4-sp-d16") set (MFPU_FPV4 "-mfpu=vfpv4") set (MFPU_FPV5_D16 "-mfpu=fpv5-d16") set (RUNTIME_LIBRARY_REDUCED_C "--specs=nano.specs") set (RUNTIME_LIBRARY_STD_C "") set (RUNTIME_LIBRARY_SYSCALLS_MINIMAL "--specs=nosys.specs") set (RUNTIME_LIBRARY_SYSCALLS_NONE "") set (MFLOAT_ABI_SOFTWARE "-mfloat-abi=soft") set (MFLOAT_ABI_HARDWARE "-mfloat-abi=hard") set (MFLOAT_ABI_MIX "-mfloat-abi=softfp") #######################################################################
и ARM тулчейн
# specify cross-compilers and tools set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g++) set(CMAKE_ASM_COMPILER arm-none-eabi-gcc) set(CMAKE_AR arm-none-eabi-ar) set(CMAKE_OBJCOPY arm-none-eabi-objcopy) set(CMAKE_OBJDUMP arm-none-eabi-objdump) set(SIZE arm-none-eabi-size) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
Таким образом я имею удобный конфигуратор в CubeIDE (который можно заменить на CubeMX) и могу писать и компилировать код в VS Code с помощью CMake, а так же это открывает возможность сборки в CI/CD.
§4. А как же отладка?
Одним из преимуществ STM32 является возможность отладки через SWD интерфейс. CubeIDE поддерживает такую возможность, а вот для VS Code придётся напрячься.
Во-первых нужны расширения, позволяющие запускать компилировать наш код и запускать отладчик, я составил для себя следующий список, который можно поместить в .vscode/extensions.json:
extensions.json
{ "recommendations": [ "dan-c-underwood.arm", "marus25.cortex-debug", "trond-snekvik.gnu-mapfiles", "mcu-debug.memory-view", "twxs.cmake", "josetr.cmake-language-support-vscode", "ms-vscode.cmake-tools", "ms-vscode.cpptools", "ms-vscode.cpptools-extension-pack", "ms-vscode.cpptools-themes" ] }
Ключевым тут является marus25.cortex-debug, т.к. именно это расширение делает всё необходимое, но ему надо помочь написав правильный launch.json. Но перед этим я поставлю OpenOCD, который будет поднимать Debug Server для VS Code. и помещу путь к нему в settings.json :
{ "cmake.useCMakePresets": "always", "cortex-debug.variableUseNaturalFormat": false, "cortex-debug.openocdPath": "${config:openOCD_dir}/openocd.exe", // Local environment "openOCD_dir" : "C:/tools/stmOpenOCD", "openOCD_CFG": "${workspaceFolder}/test.cfg", "openOCD_scripts": "${config:openOCD_dir}/st_scripts", "plink": "C:/Program Files/PuTTY/plink.exe" }
Для правильной работы OpenOCD нужен правильный конфиг test.cfg:
test.cfg
# This is an genericBoard board with a single STM32F411RETx chip # # Generated by STM32CubeIDE # Take care that such file, as generated, may be overridden without any early notice. Please have a look to debug launch configuration setup(s) source [find interface/stlink-dap.cfg] set WORKAREASIZE 0x8000 transport select "dapdirect_swd" set CHIPNAME STM32F411RETx set BOARDNAME genericBoard # Enable debug when in low power modes set ENABLE_LOW_POWER 1 # Stop Watchdog counters when halt set STOP_WATCHDOG 1 # STlink Debug clock frequency set CLOCK_FREQ 8000 # Reset configuration # use hardware reset, connect under reset # connect_assert_srst needed if low power mode application running (WFI...) reset_config srst_only srst_nogate connect_assert_srst set CONNECT_UNDER_RESET 1 set CORE_RESET 0 # ACCESS PORT NUMBER set AP_NUM 0 # GDB PORT set GDB_PORT 3333 # BCTM CPU variables source [find target/stm32f4x.cfg] # SWV trace set USE_SWO 0 set swv_cmd "-protocol uart -output :3344 -traceclk 16000000" source [find board/swv.tcl] # No way to set port, probably it is hardcoded to 3344 swv start 8 -port 3344 -traceclk 32000000 # The following commands must be sent manually # itm port 0 on # itm port start
Но есть несколько нюансов:
-
source
[find target/stm32f4x.cfg]отвечает за то какой контроллер мы собираемся прошивать и отлаживать. При смене MCU эта строчка должна быть обновлена -
Если используется SWO порт для отладки, то необходимо использовать OpenOCD из STM32CubeIDE, в противном случае всё что после строчки из пункта может быть удалено
-
Запуск терминала для вывода сообщений отладки — об этом ниже
Самый короткий путь для получения отладки это запуск putty/plink на прослушивание нужного порта. А так как удобство превыше всего, то автоматизируем это.
Создадим скрипт для запуска plink в консоли:
swo.bat
@echo off set host=localhost set port=3344 chcp 65001 cls :loop :: check if port available >nul 2>&1 (echo >\\.\%host%:%port%) if errorlevel 1 ( echo Port %port% is unavailable — wait... timeout /T 2 /NOBREAK >nul ) else ( %1 -raw %host% -P %port% ) goto loop
И сделаем task для его запуска в терминал VS Code и (чисто формальную) остановку:
tasks.json
{ "version": "2.0.0", "tasks": [ { "label": "SWO", "type": "shell", "command": "${workspaceFolder}/swo.bat", "args": ["${config:plink}"], "isBackground": true, "presentation": { "echo": true, "reveal": "always", "focus": true, "panel": "shared", "clear": true, }, "problemMatcher": [], }, { "label": "Stop SWO", "type": "shell", "command": "taskkill", "args": ["/IM", "plink.exe", "/F"], "problemMatcher": [], "hide": true, "presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "echo": false, "clear": false, "close": true }, } ], }
Теперь создадим конфигурацию «Deploy & Start» для запуска отладки вместе с терминалом:
Конфигурация в launch.json
.......... { "name": "Deploy & Start", "type":"cortex-debug", "cwd": "${workspaceRoot}", "executable": "${command:cmake.launchTargetPath}", "request": "launch", "servertype": "openocd", "gdbPath" : "arm-none-eabi-gdb", // ============== OpenOCD Config BEGIN ============== "interface": "swd", "configFiles": [ "${config:openOCD_CFG}" ], "serverArgs": [ "-s", "${config:openOCD_scripts}", "-c", "itm port 0 on", ], // ============== OpenOCD Config END ============== "runToEntryPoint": "main", // Work around for stopping at main on restart "postRestartCommands": [ "break main", "continue" ], // Work around for debugging via SWO "preLaunchTask": "SWO", "postDebugTask": "Stop SWO" }, .............
Теперь можно запускать отладку прямо в VS Code нажатием F5 и пользоваться всеми благами отладки вроде Watch, CallStack и т.д.
Для будущих проектов я создал шаблон на Github, и уже попробовал модифицировать его для STM32F103C8Tx.
OpenOCD кажется ненужным, т.к. ST-Link уже подключен и можно использовать его тулчейн, однако, OpenOCD, помимо прочего, открывает возможности удалённой отладки, которой я бы хотел воспользоваться в других проектах.
После всего выше описанного можно приступить к работе и начать то для чего всё затевалось. Продолжение следует…
Эпилог
В итоге я имею план для написания своего ПО для принтера и готовую среду для разработки. Определился с компонентной базой и чем-то похожим на архитектуру.
На момент написания статьи транзисторы для управления печатающей головкой ещё в пути и у меня есть время на небольшое изучение FreeRTOS и погружение в С на STM32, так что уже есть репозиторий с рабочим названием PrintSpider_stm32, которое впоследствии будет изменено (буду рад хорошим идеям, пока в голове только HackInkTer образованное как сокращение от Hacked Inkjet Printer), наладил CI/CD для будущих самодельцев, создал заготовку под модульные тесты на Google Tests и я начал разработку первых модулей:
-
модуль для работы с GPIO расширением и обработкой стика
-
модуль OLED дисплея и простое меню
-
абстракция над FATFS, обрабатывающая команды в отдельном потоке
-
обработчик событий
-
логгер с выводом в UART
Уже есть желание перевести проект на C++, т.к. написание даже простого UI пока ещё кажется слишком громоздким. Не знаю кого больше в сообществе любителей и знатоков C или C++, но пока нужно добиться MVP на C.
Возможно я избавлюсь от мусора, приведу в порядок стиль кода и какие-то из следующих частей будут посвящены их разбору уже написанных модулей. А пока всем спасибо за внимание и до следующей части!
ссылка на оригинал статьи https://habr.com/ru/articles/946806/
Добавить комментарий