Управлять Марсоходом из браузера, версия 2.0

android bot

Это ещё один проект - ревизия ранее созданного. Я когда-то уже делал управление машинкой из браузера, теперь хочу его повторить. Идея проекта очень простая, но интересная:

  1. на машинку устанавливаем смартфон с ОС Андроид;
  2. на смартфоне запускаем Web Server, который напишем на питоне;
  3. клиент подключается с ноутбука к серверу на смартфоне и получает HTML страницу с двумя окнами: окно видео с камеры смартфона и окно с кнопками управления;
  4. кнопки управления это Старт, Стоп, Назад, Налево, Направо, кликая мышкой в браузере на эти кнопки клиент посылает запрос на Web сервер;
  5.  Web сервер принимает запросы от клиента и воспроизводит короткий аудио файл с синусоидой, все звуки разной частоты;
  6. смартфон подключим к АЦП FPGA платы, и плата сможет оцифровывать сигнал и распознавать частоту звука;
  7. каждой частоте звука сопоставлена команда на шаговые двигатели Марсохода: соответственно ехать вперёд или назад, остановиться или поворачивать налево или неправо.
  8. таким образом, оператор ноутбука в браузере может и видеть картинку с камеры смартфона и управлять куда Марсоходу ехать.

Есть несколько причин, почему я взялся повторить те старые проекты. Во-первых, теперь я буду делать Марсоход на универсальном шасси, напечатанном на 3D принтере. Во-вторых, теперь я сделаю проект на другой плате, на Марсоход3GW2 с микросхемой FPGA Gowin и тут у меня есть АЦП, я смогу надежно распознавать звуковые команды со смартфона. И в-третьих, к сожалению, тот мой давнишний проект сегодня уже невозможно точно воспроизвести. Тогда я использовал на смартфоне приложение SL4A - Scripting Layer For Android. Эта программа позволяла мне запускать питоновский скрипт на смартфоне. А сейчас SL4A нет в Android Google Play. Да и вообще, этот проект SL4A давно заморожен и врядли вы сможете им воспользоваться.. Ну не погибать же хорошей идее! Сегодня придётся запускать питоновскую программу как-то иначе, дальше расскажу как.

Прежде всего давайте расскажу про модификацию шасси Марсохода. Про наше простое универсальное шасси было  написано вот здесь. Но есть вопрос, а как на него поставить и закрепить смартфон?

Я сделал еще пару деталей, которые нужно так же напечатать на 3D принтере и потом склеить их. Наши исходники же на гитхабе, вот там можно взять и файл дизайна FreeCAD и уже готовые STL модели: AddOns-PhoneHolder2.stl и AddOns-PhoneHolder2Line.stl. Но первую деталь нужно напечатать два раза и причем они должны быть зеркальные:

SecondFloor parts

Когда детали готовы после печати я из склеиваю дихлорметаном вот так:

SecondFloorAsm

И устанавливаю на шасси Марсохода с платой Марсоход3GW2:

SecondFloorOnCar

Потом можно поставить драйвера шаговых дигателей 28BYJ-48:

SecondFloor Marsohod Chassis On Car Drivers 28BYJ-48

Теперь на машинке Марсохода у нас есть ложе, куда можно положить смартфон:

PhoneOnCar

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

PhoneOnCarBack

Теперь перейдём к проекту в FPGA.

Весь проект для САПР Gowin FPGA Designer можно взять на github.

Модуль верхнего уровня проекта написан на Verilog HDL и выглядит так:

module top(
	input  CLK, KEY0, KEY1,
	input  [7:0] ADC_D,
	input  [7:0] FTD,
	input  [7:0] FTC,
	input  FTB0,
	output FTB1,
	output ADC_CLK,
	output [7:0] LED,
	inout [19:0] IO,
	output       TMDS_CLK_N,
	output       TMDS_CLK_P,
	output [2:0] TMDS_D_N,
	output [2:0] TMDS_D_P
);

localparam PLL_FREQ = 80000000;
localparam FREQ = (PLL_FREQ/256);
localparam LEVEL_HI = 8'hA0;
localparam LEVEL_LO = 8'h60;
localparam DIFF = 10;

localparam STATE_FORWARD  = 5'b00001;
localparam STATE_BACKWARD = 5'b00010;
localparam STATE_LEFT     = 5'b00100;
localparam STATE_RIGHT    = 5'b01000;
localparam STATE_STOP     = 5'b10000;

wire pll_out_clk;
wire pll_locked;
Gowin_rPLL rpll(
    .clkin( CLK ),
    .clkout( pll_out_clk ), // pll_out_clk = PLL_FREQ
    .lock( pll_locked )
    );

reg [23:0]cnt=0;
always @(posedge pll_out_clk)
    cnt=cnt+1;

wire adc_clk; assign adc_clk = cnt[1]; // PLL_FREQ / 4  = 20MHz
wire clk;     assign clk = cnt[7];     // PLL_FREQ / 256 = 312500 Hz

//pass clk to external ADC chip (note ADC may not work on too lower frequencies)
assign ADC_CLK = adc_clk;

//capture ADC data
reg [7:0]adc_cap = 0;
always @( posedge adc_clk )
    adc_cap <= ADC_D;

//capture ADC data
reg [7:0]adc_data0 = 8'h00;
reg [7:0]adc_data1 = 8'h00;
reg [7:0]adc_data2 = 8'h00;
reg [7:0]adc_data3 = 8'h00;
reg [7:0]overage   = 8'h00;
reg trigger = 1'b0;
reg [1:0]trigger_sr = 2'b00;
wire aedge; assign aedge = (trigger_sr==2'b01);

//measure period counter
reg [15:0]measure_cnt = 0;
//detected freqs
reg [1:0]f1000 = 2'b00;
reg [1:0]f1200 = 2'b00;
reg [1:0]f1400 = 2'b00;
reg [1:0]f1600 = 2'b00;
reg [1:0]f1800 = 2'b00;

always @(posedge clk)
begin
    adc_data3 <= adc_data2;
    adc_data2 <= adc_data1;
    adc_data1 <= adc_data0;
    adc_data0 <= adc_cap;

    overage <= (adc_data3+adc_data2+adc_data1+adc_data0) / 4;
    if(overage>LEVEL_HI) trigger <= 1'b1;
    else 
    if(overage<LEVEL_LO) trigger <= 1'b0;

    trigger_sr <= { trigger_sr[0], trigger };

    if( aedge )
        measure_cnt <= 0; //restart counting from wave top
    else
    if(measure_cnt<1023)  //do not count more then limit
        measure_cnt <= measure_cnt+1;
   
    if(measure_cnt==1023)
    begin
        //no sound waves
	f1000 <= { f1000[0] , 1'b0 };
    	f1200 <= { f1200[0] , 1'b0 };
    	f1400 <= { f1400[0] , 1'b0 };
    	f1600 <= { f1600[0] , 1'b0 };
    	f1800 <= { f1800[0] , 1'b0 };
    end
    else
    if( aedge )
    begin
		f1000 <= { f1000[0] , (measure_cnt>(FREQ/1000-DIFF)) & (measure_cnt<(FREQ/1000+DIFF)) };
		f1200 <= { f1200[0] , (measure_cnt>(FREQ/1200-DIFF)) & (measure_cnt<(FREQ/1200+DIFF)) };
		f1400 <= { f1400[0] , (measure_cnt>(FREQ/1400-DIFF)) & (measure_cnt<(FREQ/1400+DIFF)) };
		f1600 <= { f1600[0] , (measure_cnt>(FREQ/1600-DIFF)) & (measure_cnt<(FREQ/1600+DIFF)) };
		f1800 <= { f1800[0] , (measure_cnt>(FREQ/1800-DIFF)) & (measure_cnt<(FREQ/1800+DIFF)) };
    end
end

reg [7:0]state = 8'h00;
always @(posedge clk)
begin
	if(f1000==2'b01)
		state[4:0] <= STATE_FORWARD;
	else
	if(f1200==2'b01)
		state[4:0] <= STATE_BACKWARD;
	else
	if(f1400==2'b01)
		state[4:0] <= STATE_LEFT;
	else
	if(f1600==2'b01)
		state[4:0] <= STATE_RIGHT;
	else
	if(f1800==2'b01)
		state[4:0] <= STATE_STOP;
	state[7:5] <= 3'b000;
end

assign LED = state;

reg motor0_ena=1'b0;
reg motor0_dir=1'b0;
reg motor1_ena=1'b0;
reg motor1_dir=1'b0;
always @(posedge clk)
	case (state[4:0])
		STATE_FORWARD:
			begin
            //forward
            motor0_ena <= 1'b1;
            motor0_dir <= 1'b1;
            motor1_ena <= 1'b1;
            motor1_dir <= 1'b1;
			end
		STATE_BACKWARD:
			begin
            //backward
            motor0_ena <= 1'b1;
            motor0_dir <= 1'b0;
            motor1_ena <= 1'b1;
            motor1_dir <= 1'b0;
			end
		STATE_LEFT:
			begin
            //left
            motor0_ena <= 1'b1;
            motor0_dir <= 1'b0;
            motor1_ena <= 1'b1;
            motor1_dir <= 1'b1;
			end
		STATE_RIGHT:
			begin
            //right
            motor0_ena <= 1'b1;
            motor0_dir <= 1'b1;
            motor1_ena <= 1'b1;
            motor1_dir <= 1'b0;
			end
		default:
			begin
            //stop
            motor0_ena <= 1'b0;
            motor0_dir <= 1'b0;
            motor1_ena <= 1'b0;
            motor1_dir <= 1'b0;
			end
    endcase

motor motor_inst0(
	.clk(pll_out_clk),
	.enable( motor0_ena | (~KEY0) ),
	.dir( motor0_dir ),
	.cnt8( cnt[19:17] ),
	.f0( IO[ 8] ),
	.f1( IO[10] ),
	.f2( IO[12] ),
	.f3( IO[14] )
);

motor motor_inst1(
	.clk(pll_out_clk),
	.enable( motor1_ena | (~KEY1) ),
	.dir( motor1_dir ),
	.cnt8( cnt[19:17] ),
	.f0( IO[ 9] ),
	.f1( IO[11] ),
	.f2( IO[13] ),
	.f3( IO[15] )
);

//Serial_RX -> Serial_TX
assign FTB1 = FTB0;

//unused IOs to zero
assign IO[ 7: 0] = 0;
assign IO[19:16] = 0;

assign TMDS_CLK_N = 1'b0;
assign TMDS_CLK_P = 1'b0;
assign TMDS_D_N   = 4'd0;
assign TMDS_D_P   = 4'd0;

endmodule

 

Здесь дам некоторые пояснения к проекту.

Установлен экземляр PLL под именем rpll. Он выдаёт частоту 80 МГц. На этой частоте работает счётчик cnt 24 бита.

На АЦП, которое стоит на плате подаётся частота в 4 раза ниже с бита счётчика cnt[1]. То есть adc_clk это 20 МГц. Конечно, я оцифровываю звук от смартфона и мне тут такая высокая частота не нужна, но просто установленное АЦП ADC1175 на низких частотах может в приниципе не работать. Входной сигнал 8 бит ADC_D фиксируется на частоте adc_clk в регистре adc_cap.

Задаётся ещё одна рабочая частота это clk, которая в 256 раз ниже частоты с PLL, то есть получается 80МГц / 256 = 312500 Гц. Дальше почти всё идет на этой частоте.

Далее делается еще одна возможно немного странная вещь: входные данные от АЦП записываются поочерёдно в 4 последовательных регистра adc_data0, adc_data1, adc_data2, adc_data3 и получается скользящее окно на четыре элемента. Эти четыре элемента суммируются и делятся на четыре, а результат записывается в регистр overage. Это такой простейший фильтр нижних частот. Я столкнулся с небольшой проблемой, что когда на Марсоходе включаются шаговые двигатели, то они вообще-то дают сильные помехи по питанию на АЦП. Вот чтобы немного сгладить эту проблему добавлен этот фильтр.

Далее этот усреднённый сигнал overage сравнивается с двумя порогами, которые я выбрал экспериментальным образом LEVEL_HI и LEVEL_LO. При превышении первого порога регистр trigger устанавливается в единицу, при понижении ниже второго порога регистр сбрасывается в ноль. Получается бинарный сигнал частота которого равна входной аналоговой частоте с АЦП.

У регистра trigger выделяется фронт aedge. Дальше всё делается с учётом этого фронта.

Есть счетчик measure_cnt, который считает на частоте clk. Он сбрасывается по сигналу aedge, но считает так же до определенного момента, до 1023, без переполнения. Если он смог досчитать до 1023, то значит больше импульса aedge не было и значит никаких входных сигналов обнаружено не было. А вот если aedge импульс пришел, то тут уже проверяется до скольки счетчик measure_cnt смог досчитать? От этого значения выставляются регистры f1000, f1200, f1400, f1600 , f1800. Их названия говорят сами за себя. Это частоты которые я собираюсь идентифицировать в проекте.

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

В модуле Verilog верхнего уровня установлены два экземпляра модулей управления шаговыми двигателями.

Так же текущее состояние машинки отображается на светодиодах платы.

Поскольку используемая в нашем проекте плата это настоящая FPGA, то мы можем посмотреть внутренние сигналы проекта с помощью инструмента GAO (Gowin Analyzer Oscilloscope). Его использование можно включить или отключить в закладке Design дерева проекта.

К сожалению GAO не показывает сигналы в виде графиков, а только показывает числовые значения захваченных сигналов. Но за то, их можно экспортировать, например, в файл vcd и потом посмотреть с помощью GtkWave:

GtkWave

На этом рисунке как раз показан процесс распознавания входной частоты 1200 Гц из АЦП. Сигнал из АЦП как раз немного шумноват из-за работы шаговых двигателей машинки.

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

Еще один момент - куда подключать драйвера шаговых двигателей и где вход АЦП на плате? На плате Марсоход3GW2 есть разъемы для подключения дополнительных плат расширений. Это разъёмы CN2 и CN3:

Schema

Разъём CN2 расположен ближе к краю платы, а разъём CN3 ближе к HDMI. Ориентация разъёмов такова, что сигналы Земли идут как обычно по краю платы. Четыре сигнала IO[8], IO[10], IO[12] и IO[14] разъёма CN3 идут на управление фазами первого моторчика, а сигналы IO[9], IO[11], IO[13] и IO[15] - фазы второго шагового моторчика. Мы сделали просто шлейфики, которые идут к драйверам двигателей. При этом питание моторов +5В придется брать с другого разъёма CN2. Так же, для подключения аудио сигнала от смартфона придется сделать делитель напряжения на резисторах и поставить разделительный конденсатор. Я спаял себе дополнительную платку с делителем и дополнительными разъёмчиками. К этой платке подключается провод с аудио джеком к телефону.

Теперь перейду к другому - какое ПО на смартфоне.

Первое, это программа IP Webcam. Она позволяет превратить телефон с Андроид в камеру видеонаблюдения. У программы очень много настроек, которые в общем мне не нужны. Всякие опции датчиков движения и звука - всё это можно и нужно отключить. Нужна просто функция подключения браузером к телефону для просмотра изображения с камеры:

IPWebcam

Вторая программа, которая мне нужна это Pydroid3 IDE. Эта программа позволяет запускать питоновские скрипты на смартфоне Андроид.

Pydroid3

На телефон я загружаю мои зараннее подготовленные аудио файлы типа s1000.wav, s1200.wav, s1400.wav, s1600.wav, s1800.wav, а так же сам скрипт на питоне webctrl4.py

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

"""HTTP server"""

from http.server import BaseHTTPRequestHandler, HTTPServer
import socket
import urllib.parse as urlparse
import pygame
pygame.init()

HOST_NAME   = ''
PORT_NUMBER = 9090

s1000 = pygame.mixer.Sound('s1000.wav')
s1200 = pygame.mixer.Sound('s1200.wav')
s1400 = pygame.mixer.Sound('s1400.wav')
s1600 = pygame.mixer.Sound('s1600.wav')
s1800 = pygame.mixer.Sound('s1800.wav')

PAGE_TEMPLATE = '''
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>DroidBot Remote Control</title>
</head>
<FRAMESET ROWS="50%,50%">
<FRAME SRC="frame_a.html">
<FRAME SRC="frame_b.html">
</FRAMESET>
</html>
'''

PAGE_TEMPLATE_A = '''
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>DroidBot Remote Control</title>
</head>
<body>
<h1>Marsohod Remote Control</h1>
<iframe width="720" height="480" src ="http://%s:8080/video">No iframes?</iframe>
</body>
</html>
'''

PAGE_TEMPLATE_B = '''
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>DroidBot Remote Control</title>
<style type="text/css">
	#action {
		background:yellow;
		border:0px solid #555;
		color:#555;
		width:0px;
		height:0px;
		padding:0px;
	}
</style>
<script>
function AddText(text)
{
 document.myform.action.value=text;
}
</script>
</head>
<body>
<form name="myform" method="get">
		<textarea id="action" name="action">start</textarea>
		<input id="button1" type="submit" value="Start" OnClick='javascript:AddText ("start")' />
		<input id="button2" type="submit" value="Stop"  OnClick='javascript:AddText ("stop")'  />
		<input id="button3" type="submit" value="Back"  OnClick='javascript:AddText ("back")'  />
		<input id="button4" type="submit" value="Left"  OnClick='javascript:AddText ("left")'  />
		<input id="button5" type="submit" value="Right" OnClick='javascript:AddText ("right")' />
	</form>
</body>
</html>
'''

def play( id ):
	if (id=='start'):
		s1000.play()
	elif (id=='back'):
		s1200.play()
	elif (id=='left'):
		s1400.play()
	elif (id=='right'):
		s1600.play()
	elif (id=='stop'):
		s1800.play()
	
class DroidHandler(BaseHTTPRequestHandler):
    
	def do_HEAD(s):
		s.send_response(200)
		s.send_header("Content-type", "text/html; charset=utf-8")
		s.end_headers()

	def do_GET(s):
		s.send_response(200)
		
		my_full_addr = s.headers.get('Host')
		my_addr = my_full_addr.split(":",2)
		my_ip_addr = my_addr[0]
		
		url = urlparse.urlsplit(s.path)
		print( url.path )
		if url.path == '/frame_a.html':
			s.send_header("Content-type", "text/html; charset=utf-8")
			s.end_headers()
			html = PAGE_TEMPLATE_A % my_ip_addr
			s.wfile.write(html.encode())
			return
		elif url.path == '/frame_b.html':
			s.send_header("Content-type", "text/html; charset=utf-8")
			s.end_headers()
			
			query = url.query
			args = urlparse.parse_qsl(query)
		
			action = ''
			for arg in args:
				if arg[0] == 'action':
					action = arg[1].strip().replace('\r', '')
					print(action)
					play(action)
					break

			html = PAGE_TEMPLATE_B
			s.wfile.write(html.encode())
			return

		s.send_header("Content-type", "text/html; charset=utf-8")
		s.end_headers()

		html = PAGE_TEMPLATE
		s.wfile.write(html.encode())

print('web server running on port', PORT_NUMBER)
my_srv = HTTPServer((HOST_NAME, PORT_NUMBER), DroidHandler)
my_srv.serve_forever()

 

Эти все файлы являются частью проекта, можно их взять на гитхабе.

Последовательность запуска проекта на Марсоходе следующая:

1) посмотрите на смартфоне ваш IP адрес при подключении к WiFi, запомните этот адрес;

2) подключите смартфон к плате Марсоход3GW2 через аудио джек;

3) подключите питание от PowerBank машинки к плате FPGA;

4) включите на телефоне максимальную громкость звуков;

5) просто попробуйте воспроизвести какой-то из wav файлов типа s1000.wav, это Старт - на машинке должен загореться светодиод и включатся моторы машинки; запусте на проигрывание s1800.wav - моторы должны выключиться - это Стоп. Можно положить смартфон в ложе Марсохода;

6) запустите Pydroid и в нем откройте файл скрипта webctrl4.py и запустите его на исполнение;

7) запустите программу IP webcam и запустите камеру;

8) подключитесь с ноутбука из браузера по IP адресу вашего телефона и к порту 9090, у меня это адрес http://10.8.0.12:9090/

Вы должны видеть в окне браузера два HTML IFRAME: верхний показывает вид из камеры машинки, нижний показывает кнопки управления. Кликайте мышкой по кнопкам управления и смотрите, кад движется ваш Марсоход, которым вы управляете!

Смотрите, что получилось у меня:

Я надеюсь, что я подробно описал весь проект и вы тоже сможете его повторить! 

 

Добавить комментарий