воскресенье, 27 ноября 2016 г.

NAVIDOZ3R: инерциальная навигация


В нашем проекте NAVIDOZ3R, о котором сегодня пойдет речь, мы немного отойдем от хоббийных роботов и построим более привычного всем мобильного EV3-робота, способного решать довольно интересную задачу.


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


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

Данная задача - на инерциальную навигацию. Что это такое? Как гласит Википедия, инерциальная навигация — метод навигации (определения координат и параметров движения различных объектов — судов, самолётов, ракет и др.) и управления их движением, основанный на свойствах инерции тел, являющийся автономным, то есть не требующим наличия внешних ориентиров или поступающих извне сигналов. Неавтономные методы решения задач навигации основываются на использовании внешних ориентиров или сигналов (например, звёзд, маяков, радиосигналов и т. п.). Эти методы в принципе достаточно просты, но в ряде случаев не могут быть осуществлены из-за отсутствия видимости или наличия помех для радиосигналов и т.п. Необходимость создания автономных навигационных систем явилась причиной возникновения инерциальной навигации.


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

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


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


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


Программировать мы снова будем на EV3 Basic. Задумав этот проект, мы хотели в том числе пощупать его возможности по гибридному режиму работы "робот-ПК", когда часть кода исполняется на ПК, часть кода на роботе. При этом робот должен быть соединен с компьютером по USB, Bluetooth или Wi-Fi.

Давайте посмотрим на первую, гибридную программу, которая выполняется и на ПК и на роботе:

' Массивы с путевыми точками
PTx[0] = 0
PTy[0] = 0

' Показываем графическое окно
GraphicsWindow.Show()

' Очистить окно
GraphicsWindow.Clear()

' Заголовок окна
GraphicsWindow.Title = "NAVIDOZ3R"

' запрещаем изменять размеры окна
GraphicsWindow.CanResize = "False"

' Размеры окна
GraphicsWindow.Width=825
GraphicsWindow.Height=640

' Окно по центру экрана

GraphicsWindow.Left = (Desktop.Width - GraphicsWindow.Width) / 2
GraphicsWindow.Top = (Desktop.Height - GraphicsWindow.Height) / 2

' Цвет фона
GraphicsWindow.BackgroundColor = "Black"
GraphicsWindow.PenColor = "Red"

' Шрифт
GraphicsWindow.FontName = "Arial"
GraphicsWindow.FontSize = 20
GraphicsWindow.PenColor = "Black"

' фоновая картинка:
background = ImageList.LoadImage(Program.Directory + "/map1.jpg")
GraphicsWindow.DrawImage(background, 0, 0)

' Разрешение карты в пикселях
ImageX_pix = 480
ImageY_piy = 480

' Размеры поля в миллиметрах
ImageX_mm = 1117
ImageY_mm = 1116

' Добавим кнопки
btnPOI = Controls.AddButton("Сохранить маршрут", 20,GraphicsWindow.Height-100)
btnStart = Controls.AddButton("Старт", 520,GraphicsWindow.Height-100)

' Событие кнопки
Controls.ButtonClicked = OnClick

' количество путевых точек
POI = 0

Sub OnClick
  Sound.PlayClick()
  btn=Controls.LastClickedButton
  If btn="Button1" then
    GraphicsWindow.ShowMessage("Маршрут записан в робота","EV3")
    F = EV3File.OpenWrite("/home/root/lms2012/prjs/NAVIDOZ3R/NAVIDOZ3R_PT.txt")
    EV3File.WriteLine(F,POI)
    For i = 0 To POI-1
      EV3File.WriteLine(F,Math.Round(PTx[i]))
      EV3File.WriteLine(F,Math.Round(ImageY_mm - PTy[i]))
    EndFor    
    EV3File.Close(F)
  Else
    ' выход из программы
    Program.End()
  EndIf
EndSub

' Отслеживание событий нажатия кнопки мыши
GraphicsWindow.MouseDown = OnMouseDown

Sub OnMouseDown
  Sound.PlayClick()
  If POI = 0 Then
    GraphicsWindow.BrushColor = "Blue"
  Else  
    GraphicsWindow.BrushColor = "Red"
  EndIf
  GraphicsWindow.FillEllipse(x-5,y-5,10,10)
  Program.Delay(500)
  
  PTx[POI] = x*ImageX_mm/ImageX_pix
  PTy[POI] = y*ImageY_mm/ImageY_piy
  
  If POI >= 1 Then
     GraphicsWindow.DrawLine(PTx[POI-1]/ImageX_mm*ImageX_pix, PTy[POI-1]/ImageY_mm*ImageY_piy, PTx[POI]/ImageX_mm*ImageX_pix, PTy[POI]/ImageY_mm*ImageY_piy)
  EndIf
  POI = POI + 1  
EndSub

While "True"
  x = GraphicsWindow.MouseX
  y = GraphicsWindow.MouseY
EndWhile

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


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

Program.Delay(2000)
Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/load")
Speaker.Wait()
Program.Delay(1000)

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

' Предварительная попытка устранения дрифта
Sensor.SetMode(2,0)

Sensor.SetMode(3,0)
Sensor.SetMode(3,1)
Sensor.SetMode(3,0)
Sensor.SetMode(3,1)
Sensor.SetMode(3,0) 

Гироскоп EV3 подвержен неприятному явлению - дрейфу показаний (дрифту), поэтому оцениваем его в течении 5 секунд, для дальнейшей компенсации.

Time = EV3.Time
' Взятие дрифта за 5 секунд
Gyro1 = Sensor.ReadRawValue(3,0)
Program.Delay(5000)
Gyro2 = Sensor.ReadRawValue(3,0)
Drift10 = (Gyro1 - Gyro2) / 500

Получили в переменной Drift10 величину дрифта за 10 мс. Теперь открываем файл с путевыми точками и определим их количество.

F = EV3File.OpenRead("/home/root/lms2012/prjs/NAVIDOZ3R/NAVIDOZ3R_PT.txt")
Line = EV3File.ConvertToNumber(EV3File.ReadLine(F))
PT = Line

Очищаем экран и начинаем чтение файла, одновременно сохраняя путевые точки в массивы PTx и PTy с одновременным выводом на экран для контроля (робота можно взять в руки в этот момент для отладки и посмотреть что считывается из файла).

LCD.Clear()
LCD.Text(1,0,0,2,Line)

Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/plan")

For i = 0 To PT-1
  Line = EV3File.ConvertToNumber(EV3File.ReadLine(F))
  PTx[i] = Line
  LCD.Text(1,0,i*10+20,1,Line)
  
  Program.Delay(50)
  
  Line = EV3File.ConvertToNumber(EV3File.ReadLine(F))
  PTy[i] = Line
  LCD.Text(1,40,i*10+20,1,Line)
  
  Program.Delay(50)
EndFor 
EV3File.Close(F)

Program.Delay(3000)

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

' Объявление переменных
u = 0
e = 0            
e_old = 0
Pk = 4
Dk = 8

X = 0
Y = 0
X_old = 0
Y_old = 0
X_new = 0
Y_new = 0
X_tmp = 0
Y_tmp = 0

speed = 25
diam = 32.75125
Dist = 0
Azimut = 0
Azimut_old = 0
Gyro = 0
Finish = "False"
TimeTurn = 0

Далее опишем параллельную задачу (подпроцесс) в котором считываются показания гироскопа и компенсируется дрифт. Итоговые показания публикуем в переменную Gyro. Именно из нее, а не с гироскопа мы будем брать показания.

' Подпроцесс передаёт показания гироскопа с устранением дрифта
Sub Gyro
  While"True"
    Gyro = -1*(Sensor.ReadRawValue(3,0) - Drift10 * (Time/10))
  EndWhile
EndSub

Создадим процедуру, которая выводит на экран текущие координаты робота, предыдущую путевую точку и текущий азимут (направление движения). Эта информация крайне полезна при отладке и анализе текущей ситуации.

Sub Display
  
  Speaker.Note(100,"C5",500)
  
  LCD.Clear()
  
  LCD.Text(1,0,0,2,X)
  LCD.Text(1,0,20,2,Y)
  
  LCD.Text(1,0,40,1,X_old)
  LCD.Text(1,0,50,1,Y_old)
  
  LCD.Text(1,0,70,2,Azimut)
  LCD.Text(1,0,90,1,Gyro)
  
  Program.Delay(3000)
  
EndSub

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

Зная свои координаты (X,Y) и координаты целевой точки (X_new,Y_new) робот должен рассчитать две величины - расстояние, которое от должен проехать Dist и направление, в котором он должен это сделать Azimut. Расстояние Dist рассчитывается просто, по теореме Пифагора:


А направлением Azimut сложнее, в зависимости от направления движения оно рассчитывается по разному:

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

Следующая процедура, которая нам понадобится - Go_Forward - ведет робота вперед ан заданное расстояние Dist, удерживая при этом направление Azimut. Если в процессе движения на пути возникает препятствие, она вызывает одну из процедур объезда Detour1() .. Detour3().
Сбрасываем энкодеры моторов на шасси и используя ПИД-регулятор движемся вперед компенсируя отклонения показаний регулируемой величины Gyro от целевой Azimut. Следует учесть, что Azimut у нас увеличивается по часовой стрелке, а Gyro - против. 
Во время движения обновляем показания текущих координат робота (X,Y).

Sub Go_Forward
  Motor.ResetCount("BC")
  Finish = "True"
  While Math.Abs((Motor.GetCount("B")+Motor.GetCount("C"))/2) < Dist/(diam*Math.Pi)*360
    e = Azimut-(-1)*Gyro
    
    u=Pk*e+Dk*(e-e_old)
    
    Motor.Start("B",-1*speed+u)
    Motor.Start("C",-1*speed-u)
    
    e_old = e
    
    AzimutTMP = Math.Remainder(Azimut+3600,360)
    
    if AzimutTMP < 90 Then
      X_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Sin(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90)))
      Y_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Cos(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90)))
      
      X = X_old+X_tmp
      Y = Y_old+Y_tmp
    ElseIf 90 <= AzimutTMP and AzimutTMP < 180 Then
      X_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Cos(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90)))
      Y_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Sin(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90))) 
      
      X = X_old+X_tmp
      Y = Y_old-Y_tmp
    ElseIf 180 <= AzimutTMP and AzimutTMP < 270 Then
      X_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Sin(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90)))
      Y_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Cos(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90)))
      
      X = X_old-X_tmp
      Y = Y_old-Y_tmp
    ElseIf 270 <= AzimutTMP and AzimutTMP < 360 Then
      X_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Cos(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90)))
      Y_tmp = Math.Abs(Motor.GetCount("B")+Motor.GetCount("C"))/2*(diam*Math.Pi/360)*Math.Sin(Math.GetRadians(Math.Remainder(AzimutTMP+3600,90))) 
      
      X = X_old-X_tmp
      Y = Y_old+Y_tmp
    EndIf 
    
    If Sensor.ReadPercent(2)<62 Then
      Finish = "False"
      Motor.Stop("BC","True")
      Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/break")
      Speaker.Wait()
      Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/objezd")
      Speaker.Wait()
      
      Display()
      'Program.Delay(8000)
      
      X_old = X
      Y_old = Y
      
      Detour3()
      
      X_old = X
      Y_old = Y
      
      Display()
      'Program.Delay(8000)
      
      GotoXY()
      
    EndIf  
    Program.Delay(10)
  EndWhile
  Motor.Stop("BC","True")
  Program.Delay(100)
EndSub

Далее опишем три процедуры объезда препятствий. Первая из них выполняет простой объезд "по половинке ромба":
Sub Detour1
  'R = Sensor.ReadPercent(2)
  
  Azimut_old = Azimut
  Azimut = Azimut+45
  MinimumTurn()
  Turn()
  
  Dist = Math.SquareRoot(22*22+22*22)*10
  Go_Forward()  
  
  Display()
  'Program.Delay(8000)
  
  X_old = X
  Y_old = Y
  
  Azimut_old = Azimut
  Azimut = Azimut-90
  MinimumTurn()
  Turn()
  
  Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/vozvrat")
  Speaker.Wait()
  
  Dist = Math.SquareRoot(22*22+22*22)*10
  Go_Forward()
  Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/continue")
  Speaker.Wait()
EndSub

Второй вариант объезда - "по половинке квадрата" является потенциально рекурсивным, если препятствие объехать не получается, процедура объезда будет вызываться вновь и вновь до тех пор пока объезд не будет выполнен.


Sub Detour2
  'R = Sensor.ReadPercent(2)
  
  Azimut_old = Azimut
  Azimut = Azimut-90
  MinimumTurn()
  Turn()
  
  Dist = 180
  Go_Forward()  
  
  Display()
  'Program.Delay(8000)
  
  X_old = X
  Y_old = Y
  
  Azimut_old = Azimut
  Azimut = Azimut+90
  MinimumTurn()
  Turn()
  
  Dist = 360
  Go_Forward()
  
  Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/vozvrat")
  Speaker.Wait()
  
  X_old = X
  Y_old = Y
  
  Azimut_old = Azimut
  Azimut = Azimut+90
  MinimumTurn()
  Turn()
  
  Dist = 180
  Go_Forward()
    
  Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/continue")
  Speaker.Wait()
EndSub

Третий вариант объезда - "по половинке правильного многоугольника" также является рекурсивным. При большом количестве сторон получаем объезд по полукругу.

Например процедура для 12-угольника:

Sub Detour3
  
  For h=1 To 6
    
    'Display()
    'Program.Delay(8000)
    
    X_old = X
    Y_old = Y
    
    Azimut_old = Azimut
    If h = 1 Then
      Azimut = Azimut-75
    Else
      Azimut = Azimut+30  
    EndIf  
    MinimumTurn()
    Turn()
    
    Dist = 103.52
    Go_Forward()
    
    If h=5 Then
      Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/vozvrat")
      Speaker.Wait()  
    EndIf
  EndFor
  
  Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/continue")
  Speaker.Wait()
EndSub

Чтобы минимизировать углы разворота при следовании по маршруту потребуется процедура MinimumTurn(). Необходимость ее использования связана с тем, что показания гироскопа могут выходить за пределы 0..360. Она переносит текущий расчетный азимут в тот "круг", в пределах которого находятся сейчас показания гироскопа. Таким образом любой разворот не сможет превысить 180 градусов.

Sub MinimumTurn
  For j=-10 To 10
    If Math.Abs(Azimut+j*360-Azimut_old)<180 Then
      Azimut = Azimut+j*360
    EndIf
  EndFor
EndSub

Процедура Turn() выполняет поворот, ориентируя робота в нужном направлении, причем ей не важно как робот ориентирован в данный момент. Она использует ПИД-регулирование, подобно процедуре Go_forward(), однако рассчитанное результирующее воздействие не суммируется с постоянной величиной скорости, тем самым робот разворачивается на месте.

Sub Turn
  TimeTurn = EV3.Time
  While Math.Abs(Azimut-(-1)*Gyro)>=3 And EV3.Time-TimeTurn<5000
    e = Azimut-(-1)*Gyro
    
    u=Pk*e+Dk*(e-e_old)
    
    Motor.Start("B",u)
    Motor.Start("C",-1*u)
    
    e_old = e
    Program.Delay(10)  
  EndWhile
  Motor.Stop("BC","True")
  Program.Delay(100)
EndSub

Процедура GotoXY() выполняет все цепочку действий, необходимых для достижения следующей путевой точки маршрута:
  • рассчитывает новый Azimut
  • минимизирует угол разворота (выбирая кратчайший маневр) вызывая MinimumTurn()
  • разворачивает робота в заданном направлении вызывая Turn()
  • рассчитывает расстояние до следующей путевой точки Dist
  • перемещает робота, сохраняя направление, вызовом Go_forward() и выполняя объезды препятствий
Sub GotoXY
  Azimut_old = Azimut
  
  If X_old <> X_new And Y_old <> Y_new Then
    
    If X_old>X_new And Y_old>Y_new Or X_old<X_new And Y_old<Y_new Then
      Azimut = Math.GetDegrees(Math.ArcTan(Math.Abs(X_old-X_new)/Math.Abs(Y_old-Y_new)))
    Else
      Azimut = Math.GetDegrees(Math.ArcTan(Math.Abs(Y_old-Y_new)/Math.Abs(X_old-X_new)))  
    EndIf  
    
    If X_old>X_new And Y_old>Y_new Then
      Azimut = Azimut+180
    ElseIf X_old<X_new And Y_old>Y_new Then
      Azimut = Azimut+90
    ElseIf X_old>X_new And Y_old<Y_new Then
      Azimut = Azimut+270
    EndIf
  Else
    If X_old = X_new And Y_old < Y_new Then
      Azimut = 0
    ElseIf X_old = X_new And Y_old > Y_new Then
      Azimut = 180
    ElseIf X_old < X_new And Y_old = Y_new Then
      Azimut = 90
    ElseIf X_old > X_new And Y_old = Y_new Then
      Azimut = -90
    EndIf
  EndIf
  
  'Azimut = Math.Remainder(Azimut + 540, 360) - 180
  
  MinimumTurn()
  Turn()
    
  Dist = Math.SquareRoot((X_old-X_new)*(X_old-X_new) + (Y_old-Y_new)*(Y_old-Y_new))
  Go_Forward()
  
EndSub

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

' Запуск задачи Gyro
Thread.Run = Gyro

' Перед запуском блока следует передать ему значения
' через соответствующие переменные

For i = 1 To PT-1
  X_old = PTx[i-1]
  Y_old = PTy[i-1]
  X_new = PTx[i]
  Y_new = PTy[i]
  
  GotoXY()
  
  If Finish = "False" Then
    X_old = X
    Y_old = Y
    X_new = PTx[0]
    Y_new = PTy[0]
    Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/back_to_start")
    GotoXY()
    i = 1000
    'Program.End()
  Else
    If i<>PT-1 Then
      Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/poi")
      Speaker.Wait()      
      
    EndIf
  EndIf
    
EndFor

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

Azimut_old = Azimut
Azimut = 0

MinimumTurn()
Turn()

Speaker.Play(100,"/home/root/lms2012/prjs/NAVIDOZ3R/poi_finish")
Speaker.Wait()

LCD.Clear()
LCD.Text(1,0,0,2,Drift10 * (Time/10))

Gyro1 = Sensor.ReadRawValue(3,0)
Program.Delay(5000)
Gyro2 = Sensor.ReadRawValue(3,0)
Drift10 = (Gyro1 - Gyro2) / 5

LCD.Text(1,0,20,2,Drift10  * (Time/10))
Speaker.Note(100,"C4",1000)

Program.Delay(10000)

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


Скачать исходный код к нашего проекта можно в GitHub, а на видео ниже - посмотреть, как работает наш NAVIDOZ3R.



Самое популярное