В любой системе, в частности, в роботе где есть USB-устройства, постоянно борются две силы.

Робот с подключенными по USB: лидаром, ESP32 и стерео-камерой
Робот с подключенными по USB: лидаром, ESP32 и стерео-камерой

Первая — стремление к хаосу.
Устройства подключаются и отключаются, инициализируются в разном порядке, получают временные имена вроде /dev/ttyUSB0, /dev/ttyUSB1, /dev/video0. Сегодня лидар — это ttyUSB0, завтра — уже ttyUSB1. Камера, которая вчера была video0, после перезагрузки вдруг становится video2.

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

Пока в системе один USB-девайс, хаос почти незаметен.
Но как только появляется второй ESP32, лидар, камера — случайность начинает побеждать. Естественно, что это проявляется в роботе в самый неподходящий момент, например на презентации.

В этой статье я покажу, как перейти от гадания на TTY к принятию себя вызова:
закрепить USB-устройства по физическому порту, получить стабильные имена прямо в /dev (/dev/rplidar, /dev/esp32_drive, /dev/cam_left) и забыть о сюрпризах после перезагрузки.

Решение основано на стандартных механизмах Linux (by-path, systemd), хорошо масштабируется, не зависит от одинаковых устройств и отлично подходит для роботов под ROS2.

Если вы только начинаете работать с ROS2 или хотите системно разобраться, как устроено управление ROS2-роботом (ноды, топики, датчики, драйверы, launch-файлы), у меня есть бесплатный курс (да, полностью бесплатный) по ROS2, где эти темы разбираются пошагово: https://stepik.org/course/221157

Почему при использовании /dev/ttyUSB0 и /dev/video0 возникают «вопросики»?

/dev/ttyUSB0 и video0 — это не имена устройств, а временные номера, которые выдаёт система в зависимости от:

  • порядка и скорости инициализации USB;

  • наличия/отсутствия других устройств;

  • работы хабов;

  • переподключений.

Даже если сегодня лидар — это ttyUSB0, завтра он может стать ttyUSB1.

С камерами такая же история: «левая» и «правая» вдруг могут сменить, не побоюсь этого слова — взгляды, и ваш робот с удовольствием поедет, но в другую сторону.

Почему “классические” udev-правила часто не спасают

Самый популярный совет от «этих ваших» интернетов нейросетей: “сделай udev-правило”. Однако, они обучались и живут в каком-то идеальном мире, где каждое устройство пронумеровано, а если у вас несколько одинаковых устройств (например, 2–3 ESP32 на одном и том же USB-UART чипе), то их ID совпадают. Проблема не в udev как таковом, а в том, что без уникального серийника udev-правило вызывает неоднозначную ситуацию.

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

Идея решения: by-path = физический USB-порт

Linux сам создаёт стабильные пути «по проводу»:

  • Для serial-устройств:
    /dev/serial/by-path/...

  • Для V4L2-камер:
    /dev/v4l/by-path/...

Это не “красивые имена”, но они однозначно соответствуют физическому месту подключения (порт), и будут оставаться такими.
А мы поверх них создадим понятные ссылки (симлинки): /dev/rplidar, /dev/esp32_drive, /dev/cam_left

Что будем делать

  1. Находим by-path для каждого устройства (лидар, ESP32, камеры).

  2. Создаём карту соответствий в /etc/robot-devices.map.

  3. Пишем один небольшой скрипт, который создаёт симлинки в /dev.

  4. Заводим systemd-сервис, который запускается на старте.

Шаг 1. Находим by-path для serial-устройств (лидар, ESP32)

Смотрим список:

ls -l /dev/serial/by-path/

Пример вывода:

platform-xhci-hcd.1-usb-0:1:1.0-port0 -> ../../ttyUSB0
platform-xhci-hcd.0-usb-0:2:1.0-port0 -> ../../ttyUSB1

⚠️ Часто вы увидите пары строк вида usb-... и usbv2-... для одного устройства.
На мой взгляд, стоит использовать путь без usbv2 (он обычно стабильнее).

Шаг 2. Находим by-path для камер (V4L2)

Камеры живут в другом месте:

ls -l /dev/v4l/by-path/

Пример:

platform-xhci-hcd.0-usb-0:1:1.0-video-index0 -> ../../video0
platform-xhci-hcd.0-usb-0:1:1.0-video-index1 -> ../../video1

В моем случае (стереокамера):

  • video-index0 — левый сенсор

  • video-index1 — правый сенсор

⚠️ Если видите много строк вида platform-1000...codec или ...pisp... — это внутренние видеоустройства SoC (ISP/кодеки), их не используем для USB-камер.

Шаг 3. Смотрим свойства и диагностируем

Serial

udevadm info -n /dev/ttyUSB0 --query=property

Камера: v4l2-ctl

Утилиты для просмотра свойств камеры в системе может не быть:

sudo apt update
sudo apt install v4l-utils

Далее можно просмотреть список камер и их конфигурации:

v4l2-ctl --list-devices
v4l2-ctl -d /dev/video0 --all

Эти команды полезны для определения форматов/разрешений, но привязку делаем всё равно по by-path.

Шаг 4. Создаём «карту устройств» /etc/robot-devices.map

Почему /etc:

  • это стандартное место для системной конфигурации;

  • файл легко бэкапить и переносить между машинами;

  • конфигурация отделена от логики.

Создаём файл :

sudo nano /etc/robot-devices.map

Формат строки:

<type> <by-path> <final_name>

Где:

  • type:

    • serial/dev/serial/by-path/...

    • v4l/dev/v4l/by-path/...

  • by-path : путь, который вы определили в шагах 1 и 2

  • final_name — имя, которое появится в /dev

Пример:

serial /dev/serial/by-path/platform-xhci-hcd.1-usb-0:1:1.0-port0 rplidar

Шаг 5. Скрипт, который создаёт симлинки в /dev

Скрипт создадим в директории:/usr/local/sbin:

  • sbin — системные утилиты;

  • local — «наше», не перезатрётся обновлениями ОС.

Создаём:

sudo nano /usr/local/sbin/robot-dev-symlinks

Содержимое:

#!/usr/bin/env bash
set -euo pipefail

MAP_FILE="/etc/robot-devices.map"
OUT_DIR="/dev"
WAIT_SEC=10

while IFS= read -r line; do
  [[ -z "${line// }" || "${line:0:1}" == "#" ]] && continue

  type=$(echo "$line" | awk '{print $1}')
  src=$(echo "$line" | awk '{print $2}')
  name=$(echo "$line" | awk '{print $3}')

  dst="$OUT_DIR/$name"

  # ждём появления by-path (на старте система может инициализировать USB не мгновенно)
  for ((i=0;i<WAIT_SEC*10;i++)); do
    [[ -e "$src" ]] && break
    sleep 0.1
  done

  [[ ! -e "$src" ]] && continue

  ln -sfn "$src" "$dst"
  echo "[robot-dev] $dst -> $(readlink -f "$src")"

done < "$MAP_FILE"

Делаем исполняемым:

sudo chmod +x /usr/local/sbin/robot-dev-symlinks

И запускаем вручную:

sudo /usr/local/sbin/robot-dev-symlinks

А затем быстро отфильтровать нужные строки из /dev (возможно, вам нужно будет поменять параметры отбора):

ls -l /dev | grep -E 'rplidar|esp32|cam'

Пояснение:

  • ls -l /dev показывает всё в /dev;

  • grep -E включает расширенные регулярные выражения;

  • rplidar|esp32|cam означает “показать строки, где встречается rplidar или esp32 или cam”.

Шаг 6. Поднимаем всё через systemd-сервис

Почему systemd:

  • управляет порядком запуска (важно, чтобы udev успел создать by-path);

  • даёт логи (journalctl);

  • работает стабильно и предсказуемо.

Создаём unit:

sudo nano /etc/systemd/system/robot-devices-symlinks.service

Содержимое:

[Unit]
Description=Create stable device symlinks in /dev (by-path)
After=systemd-udevd.service
Wants=systemd-udevd.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/robot-dev-symlinks
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

Активируем:

sudo systemctl daemon-reload
sudo systemctl enable --now robot-devices-symlinks.service

Проверяем статус и логи:

sudo systemctl status robot-devices-symlinks.service
journalctl -u robot-devices-symlinks.service -b --no-pager

Реальный конфиг на текущий момент

Вот как выглядит файл /etc/robot-devices.map на моём роботе:

# Serial
serial /dev/serial/by-path/platform-xhci-hcd.1-usb-0:1:1.0-port0 rplidar
serial /dev/serial/by-path/platform-xhci-hcd.0-usb-0:2:1.0-port0 esp32_drive

# Stereo-camera
v4l /dev/v4l/by-path/platform-xhci-hcd.0-usb-0:1:1.0-video-index0 cam_left
v4l /dev/v4l/by-path/platform-xhci-hcd.0-usb-0:1:1.0-video-index1 cam_right

В результате после загрузки робота всегда получаю:

/dev/rplidar     -> /dev/ttyUSB0 (или другой, но симлинк стабилен)
/dev/esp32_drive -> /dev/ttyUSB1
/dev/cam_left    -> /dev/video0
/dev/cam_right   -> /dev/video1

И дальше в ROS2 использую только эти имена:

  • /dev/rplidar

  • /dev/esp32_drive

  • /dev/cam_left, /dev/cam_right

Итоги

Этот подход решает сразу несколько практических проблем:

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

  • одинаковые ESP32/камеры больше не путаются;

  • конфиги и launch-файлы становятся переносимыми и понятными;

  • система дружит с “готовыми” ROS-драйверами, которые ждут стандартные пути.

Если вы строите робота и хотите, чтобы «вчера работало — и завтра работало», то привязка по by-path — один из самых надёжных и простых способов.