Robotic Operation System позволяет взаимодействовать своим подсистемам по механизмам «подписка на топик» и «вызов сервиса» по своему специальному протоколу. Но есть пакет rosbridge, который позволяет общаться с ROS извне с помощью websocket. Описанный протокол позволяет выполнять основные операции по взаимодействию с другими подсистемами.

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

Я решил совместить приятное с полезным и изучать ROS (по которой сейчас идет курс) и ELM вместе.

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

ELM использует паттерн model-updater-view. Состояние программы описывается типом данных Model, функция update берет входящие события типа Msg и преобразует старую модель в новую (и, возможно, операцию, которую надо выполнить), а функция view по модели строит ее представление в пользовательком интерфейсе, который может порождать события типа Msg. Еще события могут приходить по подпискам, которые создаются специальной функцией из модели.

Обобщенная web-программа на ELM выглядит так:

init : ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Cmd Msg )
view : Model -> Html Msg
subscriptions : Model -> Sub Msg
main =
  Html.program
    { init = init
    , view = view
    , update = update
    , subscriptions = subscriptions
    }

а программисту остается только реализовать эти четыре функции.

Опишем модель:

type alias Model =
  { x : Float
  , y : Float                      -- координаты черепашки
  , dir : Float                    -- направление, в котором черепашка смотрит

  , connected : Bool          -- подключенность к серверу
  , ws : String                  -- URL websocket, который слушает rosbridge
                                   -- если ROS запущен на рабочей машине
                                   -- и все настроено поумолчанию,
                                   -- url будет ws://localhost:9090/
  , topic : String               -- топик, по которому управляется черепашка,
                                   -- обычно /turtle1/cmd_vel

  , input : String              -- JSON сообщение, которое мы можем редактировать
                                  -- и отправить в систему руками
  , messages : List String  -- Пришедшие со стороны rosbridge сообщения
                                   -- эти поля требуются только для отладки
                                   -- и в исследовательских целях
  }

init : ( Model, Cmd Msg )
init =
  ( Model 50 50 0 False "ws://192.168.56.101:9090/" "/turtle1/cmd_vel" "" []
  , Cmd.none
  )

Пока ни чего сложного, модель представляет из себя структуру с именованными полями.
Тип Msg устроен менее привычно для ОО-программистов:

type Msg
  = Send String
  | NewMessage String
  | EnterUrl String
  | EnterTopic String
  | Connect
  | Input String

Это так называемый алгебраический тип, описывающий прямую (размеченную) сумму нескольких альтернатив. Наиболее близкое предстваление этого типа в ООП — Msg объявляется абстрактным классом, а каждая строка алитернативы описывает новый, унаследованный от Msg, конкретный класс. Input, Send и прочее — это имена-конструкторы этих классов, за которыми следуют параметры конструктора, которые превращаются в поля класса.

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

  • Send String — запрос на отправку строки в websocket
  • NewMessage String — обработать принятую из websocket строку
  • EnterUrl String — редактируется url для websocket
  • EnterTopic String — редактируется топик
  • Connect — закончить редактирование настроек и связаться с сервером
  • Input String — редактирование «ручного» сообщения в websocket

Теперь более-менее понятно, как реализовать функцию update:

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    EnterTopic newInput
     -> ( { model | topic = newInput }, Cmd.none )
    EnterUrl newInput
     -> ( { model | ws = newInput }, Cmd.none )
    Connect
     -> ( { model | connected = True }, WebSocket.send model.ws (subscr model.topic) )
    Input newInput
     -> ( { model | input = newInput }, Cmd.none )
    Send data
     -> ( { model | input = "" }, WebSocket.send model.ws data )
    NewMessage str
     -> case Decode.decodeString (decodePublish decodeTwist) str of
          Err _
           -> ( { model | messages = str :: model.messages }, Cmd.none )
          Ok t
           -> let ( r, a ) = turtleMove t.msg
                  dir = model.dir + a
              in  ( { model
                    | x = model.x + r * sin dir
                    , y = model.y + r * cos dir
                    , dir = dir
                    , messages = str :: model.messages
                    }
                  , Cmd.none
                  )

Здесь используются несколько функций, которые мы определим позднее:

  • subscr: String -> String — конструирует строку запроса для подписки на топик в rosbridge
  • (decodePublish decodeTwist) — декодирование сообщения от топика, содержащее данные ROS-типа geometry_msgs/Twist, с которыми оперирует черепашка
  • turtleMove: Twist -> ( Float, Float ) — извлечение из сообщения перемещения и угла поворота черепашки

А пока определим функцию view:

view : Model -> Html Msg
view model =
  div [] <|
    if model.connected
    then let x = toString model.x
             y = toString model.y
             dirx = toString (model.x + 5 * sin model.dir)
             diry = toString (model.y + 5 * cos model.dir)
         in  [ svg [ viewBox "0 0 100 100", Svg.Attributes.width "300px" ]
                 [ circle [ cx x, cy y, r "4" ] []
                 , line [ x1 x, y1 y, x2 dirx, y2 diry, stroke "red" ] []
                 ]
             , br [] []
             , button [ onClick <| Send <| pub model.topic 0 1 ]
                 [ Html.text "Left" ]
             , button [ onClick <| Send <| pub model.topic 1 0 ]
                 [ Html.text "Forward" ]
             , button [ onClick <| Send <| pub model.topic -1 0 ]
                 [ Html.text "Back" ]
             , button [ onClick <| Send <| pub model.topic 0 -1 ]
                 [ Html.text "Rigth" ]
             , br [] []
             , input [ Html.Attributes.type_ "textaria", onInput Input ] []
             , button [ onClick (Send model.input) ] [ Html.text "Send" ]
             , div [] (List.map viewMessage model.messages)
             ]
    else [ Html.text "WS: "
         , input
             [ Html.Attributes.type_ "text"
             , Html.Attributes.value model.ws
             , onInput EnterUrl
             ]
             []
         , Html.text "Turtlr topic: "
         , input
             [ Html.Attributes.type_ "text"
             , Html.Attributes.value model.topic
             , onInput EnterTopic
             ]
             []
         , br [] []
         , button [ onClick Connect ] [ Html.text "Connect" ]
         ]

viewMessage : String -> Html msg
viewMessage msg = div [] [ Html.text msg ]

view создает DOM (можно чтитать, что просто html). Каждый объект (тег) генерируется отдельной функцией из библиотеки «elm-lang/html», которая принимает два параметра — список аттрибутов, типа Html.Attribute и список вложенных объектов/тегов. (Лично я считаю такое решение неудачным — я как-то поместил вложенный элемент в тег br и потом долго не мог найти его на экране, правильная библиотека не должна позволить сделать такую ошибку, оставив у br только аргумент с аттрибутами. Но возможно, в таком подходе есть глубокий смысл для специалистов во фронтетде.)

Отдельно я хочу описать аттрибуты. Тип Html.Attribute — это сборная-солянка для совершенно разнородных сущностей. Например Html.Attributes.type_ : String -> Html.Attribute msg задает тип в таких тегах, как imput, а Html.Events.onClick : msg -> Html.Attribute msg задает событие, которое должно произойти при клике на этот элемент.

Полностью прописать Html.Attributes.type_ в коде пришлось из за конфликта с Svg.Attributes.type_.

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

onClick <| Send <| pub model.topic 0 1

Он эквивалентен

onClick (Send (pub model.topic 0 1))

<| — это оператор применения функции к аргументу (в Haskell он называется '$'), который позволяет использовать меньше скобок.

onClick — уже рассмотренная создания аттрибута, ее параметр — генерируемое событие.

Send — один их конструкторов типа Msg, ее патаметр — строка, которую мы хотим потом отправить в websocket.

Конструкторы и типы в ELM пишутся с большой буквы, а переменные (точнее константы и параметры функций), обычные и типовые, с маленькой.

pub model.topic 0 1 — вызов функции создания запроса на отправку сообщения о движении черепашки на топик. Топик берется из модели, а 0 и 1 — перемещение и поворот.

Опишем недостающие функции. Проще всего создавать сообщения для отправки в websocket, так как это просто строки:

subscr : String -> String
subscr topic = "{\"op\":\"subscribe\",\"topic\":\"" ++ topic ++ "\"}"

pub : String -> Float -> Float -> String
pub topic m r =
  "{\"topic\":\""
    ++ topic
    ++ "\",\"msg\":{\"linear\":{\"y\":0.0,\"x\":"
    ++ toString m
    ++ ",\"z\": 0.0},\"angular\":{\"y\":0.0,\"x\":0.0,\"z\":"
    ++ toString r
    ++ "}},\"op\":\"publish\"}"

С обработкой сообщений немного сложнее. Тип сообщения, с которым работает turtlesim можно посмотреть средствами ROS:

ros:~$ rosmsg info geometry_msgs/Twist
geometry_msgs/Vector3 linear
  float64 x
  float64 y
  float64 z
geometry_msgs/Vector3 angular
  float64 x
  float64 y
  float64 z

rosbridge его превращает в json и заворачивает в сообщение о событии на топике.

Декодирование его будет выглядеть так:

type alias Vector3 = ( Float, Float, Float )

type alias Twist = { linear : Vector3, angular : Vector3 }

decodV3 : Decode.Decoder Vector3
decodV3 =
  Decode.map3 (,,)
    (Decode.at [ "x" ] Decode.float)
    (Decode.at [ "y" ] Decode.float)
    (Decode.at [ "z" ] Decode.float)

decodeTwist : Decode.Decoder Twist
decodeTwist =
  Decode.map2 Twist
    (Decode.at [ "linear" ] decodV3)
    (Decode.at [ "angular" ] decodV3)

type alias Publish a = { msg : a, topic : String, op : String }

decodePublish : Decode.Decoder a -> Decode.Decoder (Publish a)
decodePublish decMsg =
  Decode.map3 (\t m o -> { msg = m, topic = t, op = o })
    (Decode.at [ "topic" ] Decode.string)
    (Decode.at [ "msg" ] decMsg)
    (Decode.at [ "op" ] Decode.string)

Декодер Json-представления некоторого типа комбинируется из других декодеров.
Decode.map3 (,,) применяет три декодера, переданные ему в параметрах, и создает тупл из трех декодорованных элементов с помощью операции (,,).

Decode.at декодирует величину, извлеченную по данному пути в Json заданным декодером.

Код

(\t m o -> { msg = m, topic = t, op = o })

описывает замыкание. Он аналогичен коду на js:

function (t,m,o) { return {"msg":m, "t":t, "op":p} }

Полный код можно взять с github.

Если есть желание попробовать ROS придется установить самостоятельно. Вместо установки ELM можно воспользоваться сервисом.

Комментарии (4)


  1. Fervus
    20.10.2017 20:21

    Большое спасибо за интересную статью. Это отлично, что появляются курсы и такие статьи по ROS'у. Крайне интересно узнать про ваш опыт:


    1. Возникли какие-либо сложности при установке/настройке ROS'a. Если да, то какие?
    2. Правильно ли я понял, что такой способ работы через rosbridge по функционалу полностью аналогичен нодe с Publisher and Subscriber?
    3. Как вы думайте, rosbridge подходит для полноценной работы с ROS? Не будет ли проблем со скоростью из-за отсутствия сериализации в бинарный протокол и использования WS вместо TCP или же область применения rosbridge ограничивается использованием в веб-интерфейсах для ручного управления и мониторинга?


    1. potan Автор
      20.10.2017 20:38

      Особых проблем не возникло. Дома я установил на ноут со старой ubuntu, на работе в виртуалке со свежей xubuntu. Была мелкая проблема с самой виртуалкой — 64-битная OS в ней не пошла, пошла только 32-битная версия, но если VirtualBox сконфигурирован под 64-битную ubuntu.
      Судя по описанию протокола можно так же использовать и создавать сервисы. Но я пока не пробовал.
      В документации я не нашел средств получания метаинформации — списков и описаний пакетов, топиков, типов, сервисов. А с ними бы из web-интерфейса было бы интересно поработать. Мне кажется, rosbrige вполне подойдет для мониторинга и управления (типа SCADA или чего-то похожего на виртуальную реальность) даже в промышленном применении. В учебном применении время реакции не столь критично, и на нем можно прототипировать и что-то более низкоуровневое. Я хочу сделать фреймворк для ELM, который бы позволял работать с ROS как из web, так и через nodejs. В последнем случае, вероятно, без UI. Надеюсь, это расширит область применимости ELM.


  1. Fervus
    20.10.2017 23:04
    +1

    В документации я не нашел средств получания метаинформации — списков и описаний пакетов, топиков, типов, сервисов.

    Посмотрите этот проект: ros-control-center. Судя по скриншоту и описанию (ROS Control Center offers an easy way to show nodes, topics and service names.) им удается получать метаинформацию.


    По поводу мониторинга, у них ряд интересных проектов: robotwebtools


    В учебном применении время реакции не столь критично, и на нем можно прототипировать и что-то более низкоуровневое.

    Как понимаю, при использовании ROS, ноды находят друг друга через Master'a, а в дальнейшем коммуницируют напрямую. В случае использования rosbridge коммуникация происходит через ноду-посредника на котором запущен WS сервер. Чисто теоретически, при большом количество топиков и подписчиков, такая нода может стать узким местом. Вопрос, правда, на сколько это критично.


    1. potan Автор
      23.10.2017 12:14

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

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