Привет, Хабр! Меня зовут Юрий Леметюйнен. Сейчас в «Лаборатории Касперского» я занимаюсь тестированием железа в процессе разработки KasperskyOS для мобильных устройств.

Каждая железка — произведение искусства, к которому нужен особый подход. При тестировании нашей ОС для смартфона мы столкнулись с тем, что часть багов тачскрина невозможно стабильно воспроизвести и проверить ни в эмуляторе, ни ручным тестированием. В итоге для решения этой проблемы пришлось собрать своего дельта-робота с «робопальцем», который умеет тапать и свайпать по экрану смартфона.

В тексте погружусь во все основные этапы создания такого «пальца»: от исследования рынка сборки роботов до написания первых тестов. Подкреплю все примерами кода и рассказами об основных проблемах. Текст подойдет для всех, кому интересно сделать собственного робота или просто нравятся креативные способы упрощения автотестов :)

Как мы не устаем объяснять, KasperskyOS — микроядерная операционная система, в которой наши принципы безопасности сочетаются с известными архитектурными концепциями и подходами. Безопасность в ней достигается за счет своего ядра: это не дистрибутив Linux, не версия Android, а полностью наша собственная разработка.

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

Подробнее о тестах

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

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

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

Например, при анализе энергопотребления нам нужно узнать, сколько смартфон тратит заряда, когда пользователь нажимает на экран. Энергозатратность физического нажатия через эмуляцию никак не отловить. А нам с командой надо знать, насколько больше заряда съедается при тапе, чем без него.

У нас сложилась экосистема автоматического тестирования, в которой есть:

  • мобильные устройства;

  • внешняя камера, через которую мы проверяем состояние этих гаджетов;

  • эмулятор базовой станции — чтобы мы понимали, как девайс работает с сотовой связью;

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

 Система хорошая, но ряд сценариев на ней протестировать сложно.

Нестандартные баги

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

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

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

Юзер нажимает кнопку клавиатуры на экране, но печатается не та буква. Пишешь сообщение, получается полная белиберда. Но на эмуляторе при таком же нажатии печатается корректная буква.

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

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

Мы осознали, что в существующую экосистему тестирования нужно внедрить средство, которое сможет проверять драйвер тачскрина и взаимодействовать с KasperskyOS.

Нам нужен робот, но какой?..

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

В итоге получилась такая штука. Теперь пройдем путь ее создания.

Создаем собственного робота

Инженеры нашли два рыночных решения. Одно из них оказалось закрытым для общего пользования. А вот в основе второго лежали open-source-проекты, которые мы могли бы интегрировать у себя. Поэтому мы вступили в диалог с его разработчиками. Сделка в итоге со��валась, но мы не сдались и запросили у создателей робота Bill of Materials. Нам его предоставили, и мы начали разработку.

Вот так выглядит Delta Robot
Вот так выглядит Delta Robot

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

Для работы робота нужно решить две задачи:

  • Когда мы знаем положение манипулятора в пространстве, нам надо вычислять положение двигателя.

  • Когда мы знаем положение двигателя, нам необходимо узнавать точное положение манипулятора в зависимости от состояния двигателей.

Хорошие новости: эти задачи давно решены. Благодаря формулам для обратной кинематики (когда по известному положению манипулятора вычисляются углы поворота двигателей) и прямой кинематики (когда по известным углам двигателей находится положение манипулятора путем решения системы уравнений) получилось настроить систему так, чтобы тестировщик видел положение «плечей», двигателя и «робопальца» одновременно.

Выбор железа

Когда мы разобрались с этим, приступили к выбору двигателя. Закупили две модели: тесты показали, что лучше всего подходит DYNAMIXEL. Конкурент при передвижении неправильно рассчитывал координаты, поэтому начинал шататься из стороны в сторону, пытаясь прийти в нужное положение — и в итоге застревал в бесконечном цикле.

В качестве «мозга» нашего робота взяли ESP32-DevKitC-32D. В открытом доступе можно найти очень много прошивок. Если нам нужно, мы можем использовать Wi-Fi или Bluetooth. Чтобы не подключать ESP напрямую через пины, наш инженер разработал отдельную плату. Через которую мы можем подать питание, подключить наши двигатели и управлять ими, как нам хочется.

В качестве прошивки мы выбрали FluidNC, она находится в открытом доступе и работает на G-Code. Он позволяет нам на основе внутренней модели кинематики управлять движками. Например, чтобы наша прошивка работала корректно, нужно задать физические параметры модели. Они либо задаются из STEP-файла самой модели, либо просто измеряются в реальности линейкой и передаются в заголовочный файл прошивки.

Но тестовая модель при повторном запуске начинала двигаться в другую сторону! Мы полезли обратно в прошивку и выяснили, что каждый двигатель поставляется с дефолтным ID-шником, равным 1. То есть при автоопределении они были на одном уровне и могли просто меняться местами.

Чтобы это автоопределение отключить, мы закупили дополнительный контроллер U2D2.

Он статически записывает ID-шник внутри двигателя.

#define X DYNAMIXEL ID 1 // protocol ID
#define Y DYNAMIXEL ID 2 // protocol ID
#define Z DYNAMIXEL ID 3 // protocol ID

Ну и сам палец.

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

Давайте посмотрим на процесс нажатия.

Не нажимает! Чтобы понять почему, надо возвращаться к физике. В емкостном экране при нажатии между конденсаторами возникает утечка тока, на которую реагирует датчик.

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

Для решения проблемы пришлось заземлить манипулятор.

За это отвечает черный проводок. Теперь смотрим на корректную работу.

Не просто нажимает, а свайпает! Как это получилось? Здесь переходим к этапу, на котором к разработке подключился непосредственно я.

Автоматизация

Инженер собрал Delta-робота, отдает его мне и говорит: «Автоматизируй!»

Дело непростое. Нажать-то палец сможет. Но как понять, что он нажимает туда, куда нужно? Пути два. Нужно либо смотреть с внешней камеры устройства и через распознавание изображений (реализуемое с помощью библиотеки OpenCV) проверять, что система отработала корректно. Либо получать координаты напрямую из логов телефона.

Я подумал, что второй вариант будет удобнее и быстрее. Как раз совпало, что, когда эта задача пришла ко мне, у нас в ОС меняли систему логирования. Пришлось научиться писать на Flutter, чтобы создать приложение, которое возвращает координаты по нажатии. Опыт прикольный, расширил свои границы компетенций (подробнее про опыт работы с Flutter мой коллега рассказал тут :)).

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

Ищем общий язык с железом

Итак, передо мной подключенный по USB ESP32-робот с многообразием рук и пальцем. Подключиться к нему, используя Python, несложно. Берем библиотеку Serial: для инициализации она требует порт, частоту взаимодействия и тайм-аут. Если это Linux-порт, то ему надо выдать права, COM-порту в Windows такое не требуется.

# Библиотека для основного взаимодействия
from serial import Serial
from serial.serialutil import SerialException

serial_device = Serial(
  # Linux порт, COM для Windows
  port = "/dev/ttyUSB0",
  # Частота взаимодействия
  baudrate = 115200,
  timeout = 5.0,
  # Таймаут для записи
  write timeout = 2.0
)

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

# Очистим ввод-вывод

serial_device.flushInput() 
serial_device.flushOutput()

Чтобы передать команду, достаточно написать небольшую «обертку» для упрощения кода вокруг стандартной функции write.

def send_command(serial_device : Serial, cmd: str) -> str:
""" Отправка команды на устройство """
resp = serial_device.write(f"{cmd}\n'.encode())
return resp.decode().strip()

Когда мы подключаемся по serial-порту, данные идут обычным байт-кодом. Поэтому надо энкодить отправку и декодить получение. В первый раз это не сработало. Я полез в документацию и увидел, что прошивка требует символ переноса строки, потому что она так воспринимает команды. Если отправить ей несколько раз команды без переноса строки, она просто их не примет.

На каком языке говорит робот? Основа в прошивке — G-Code, а FluidNC позволяет расширить ее дополнительными командами. Например, G1 — координированное движение по осям XYZ.

G1 X10 Y-10 Z-126

Если вы знакомы с 3D-принтерами, то знаете эту команду. Она отличается от G0 только подачей материала: при G1 печатаем, при G0 просто двигаемся. Так как подачи материала у нас нет, просто использовать G1 — рабочий ход.

Скорость подачи (F) определяется физическими параметрами движков. В данном случае:

G1 F35000

 Палец движется очень быстро.

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

$T

Может возникнуть ошибка — например, мы произвели тест, но забыли вернуть его в начальное положение.

Grbl 3.0 [FluidNC v3.0.x (noGit) (wifi) '$' for help]
ok
State 0 (Idle)
ok
ok
ok
[MSG:INFO: ALARM: Soft Limit]
ALARM:2
[MSG: ERR: Reset to continue]
ok
error:8

При переподключении прошивка не перезапускается по питанию, робот остается в измененном состоянии. Если попытаться ввести ему относительные координаты, он выдаст ошибку и больше не будет принимать G-код.

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

\x18

 После этой команды робопалец возвращается в изначальное положение.

 Чтобы передать роботу координаты, нужно еще написать небольшую обвязку вокруг отправки команды. Здесь нужна g-кодовая команда.

def move(serial_device, x_coord: int, y_coord: int, z_coord: int):
  """ Перемещение манипулятора по координатам """
  return send_command(

    serial device,
    f"G1 X{x_coord} Y{y_coord} Z{z_coord}”
  )

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

# Заранее подготовленные координаты 
from lib.coordinates import Coordinates

def touch(serial_device, x_coord: int, y_coord: int, sleep: float):
  """ Произвести нажатие """
  move(serial device, x coord, y coord, coordinates.default height)
  sleep(_sleep)
  move(serial device, x coord, y coord, coordinates.touch height)
  sleep(_sleep)
  move(serial device, x_coord, y_coord, coordinates.default_height)

Для большего контроля пальца мы добавляем sleep(_sleep): благодаря ему робот успевает переместить палец в нужное положение. Без этого он получает несколько команд на вход и может слишком быстро их выполнить, из-за чего девайс не успеет среагировать.

Свайп реализуется аналогично с тачем.

def swipe(
    serial_device,
    start: Tuple[int, int],
    finish: Tuple[int, int],
    _sleep: float
  ):
  """ Произвести свайп """
  move(serial_device, *start, coordinates.default_height)
  sleep(_sleep)
  move(serial_device, *start, coordinates.touch_height)
  sleep(_sleep)
  move(serial_device, *finish, coordinates.touch_height)
  sleep(_sleep)
  move(serial_device, *finish, coordinates.default_height)

Final Boss: готовимся к тестам

Для начала нужно описать все это в классе.

class DeltaRobot:
    def __init__(
        self,
        device_path: str = "/dev/ttyUSB0",
        baudrate=115200,
        timeout: float = 5.0
    ):
        try:
            self.serial_device = Serial(device_path, baudrate=115200, timeout=timeout)
            self.coordinates: Coordinates = Coordinates()
            self.serial_device.flushInput()
            self.serial_device.flushOutput()
            self.send_command("\x18")
            self.send_command("$T")
            self.send_command("G1 F35000")
        except SerialException as err:
            raise DeltaException(f"Failed to connect:{device_path}", err) from err
        except TimeoutError as err:
            raise DeltaException(f"Timeout {timeout}s", err) from err

    def .......

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

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

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

Класс оборачиваем в фикстуру.

import pytest
from lib.delta_robot import DeltaRobot

@pytest.fixture(scope="function")
def get_delta_robot(request) -> DeltaRobot:
    """
    Фикстура с дельта-роботом.
    (Fixture with delta robot.)
    """
    
    def teardown():
        delta_robot.serial_device.close()

    request.addfinalizer(teardown)
    delta_robot: DeltaRobot = DeltaRobot()
    return delta_robot

Импортируем pytest, объявляем фикстуру, импортируем наш класс Delta-робота. Когда фикстура исполнена, мы отключимся, чтобы не был занят порт.

Наконец, пишем сам тест. Он у меня здесь маленький, но очень показательный.

class TestTouchScreen:
    """ Класс для тестов тачскрина """
    def test_unlock(self, get_delta_robot, test_env):
        """
        Разблокировка экрана.
        (Screen unlock.)

        :param test_env: Тестовое окружение.
        :param get_delta_robot: Дельта-робот.
        """

        dut = test_env.device_under_test

        delta_robot = get_delta_robot
        delta_robot.swipe(
            delta_robot.coordinates.down_middle,
            delta_robot.coordinates.up_middle
        )

        assert dut.is_screen_locked() is False

Этот тест разблокирует экран. Я передаю ему нашего Delta-робота, тестовое окружение, а уже из окружения он получает нужный девайс. После этого робот свайпает, а тест проверяет, что экран разблокирован.

Итоги

Cо старта работы до готового робопальца прошло полтора года, но путь был интересный и плодотворный, а тестировать теперь гораздо проще. Решение идеально интегрируется в нашу экосистему тестирования: с ним мы проверяем драйвер тачскрина и можем автоматизировать другие сценарии, например проверки энергопотребления и температуры.

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

Ну а если у вас были какие-то подобные эксперименты по упрощению тестирования — смело делитесь своим опытом в комментариях :-)