Gamepad
- 10 minutes read - 2122 wordsRecentemente comprei uma raspberry pi 4 com o propósito de usar o projeto retropie para jogar video game do século passado. Depois que comprei, lembrei que precisava também comprar controles (gamepads).
Pesquisando na documentação do retropie, verifiquei que existe opções de usar um
virtual gamepad
e as duas soluções mais usadas são
Mobile-Gamepad e
Virtual-Gamepad.
Ambas as soluções funcionam do mesmo jeito sendo compostas por:
- frontend em javascript simulando um controle e enviando eventos dos botões e direcionais para
- backend nodejs usando socket.io para receber os eventos e escrever no dispositivo /dev/uinput.
uinput é um módulo do kernel linux no qual emula inputs de uma aplicação para serem entregues e manipulados por outros processos em userlandQualquer aplicação ou código que execute fora do kernel do sistema operacional. ou in-kernelEspaço reservado para execução de códigos dentro do kernel do Sistema Operacional, device drivers e outros códigos privilegiados. .
Bom, para resolver o meu problema dos controles, bastaria usar uma solução já pronta. Mas resolvi reimplementar utilizando Elixir e o framework Phoenix. O nome deste projeto é epad e o objetivo é criar um controle virtual onde posso usar um celular conectado via websocket e enviando eventos a partir de uma aplicação web em javascript.
Então, neste post vamos falar sobre:
- Elixir
- Phoenix Framework
- Driver em Erlang
- Release com cross compileÉ uma técnica utilizada para compilar uma aplicação em uma determinada plataforma na qual não é, necessariamente, a mesma plataforma que irá executar a aplicação.
Arquitetura
epad
é composto de três principais blocos:
- epad_web, abre e gerencia websockets entre o servidor e o cliente web
- epad, faz a gestão entre usuários (players) e o driver que controla o device uinput
- driver, recebe eventos de um processo e escreve no respectivo descritor do device uinput utilizando ioctl e write
epad_web e epad foram escritos em Elixir. Já o driver (epad_driver) foi escrito em C.
Erlang/OTP oferece alguns modos quando precisamos interfacear com APIs do sistema operacional ou outras bibliotecas feitas em C/C++. Para este projeto escolhi utilizar driver.
Outro ponto foi a escolha do Phoenix no qual possui uma gestão dos websockets e com uma abstração na qual cada cliente tenha um processo representando um usuário. Tudo passa a ser uma questão de envio e recebimento de mensagens.
A figura abaixo mostra os principais blocos da solução:
Sobre a árvore de supervisão, para este projeto escolhi criar algo usando DynamicSupervisor pois, dependendo do jogo, podemos ter vários players conectados.
Detalhes do driver epad
epad acompanha um driver Erlang implementado em C, responsável por receber um evento, decodificar e chamar as funções nativas do Sistema Operacional para interfacear com o Kernel Linux.
Sempre quando quisermos integrar códigos externos vamos precisar usar uma das opções baixo:
- NIFs
- Port Drivers
- Ports
- Erl Interface
- C Nodes
A melhor documentação para iniciar neste contexto é o manual Interoperability Tutorial User’s Guide.
Cada opção serve para determinado caso de uso. Para a aplicação epad, uma boa
opção é utilizar um Port Driver
.
A implementação foi feita no arquivo: src/epad_driver.c.
Os detalhes de como escrever drivers merecem um post separado. Neste post vou apenas comentar os pontos principais.
Todo driver começa com uma declaração das callbacks que serão implementadas bem como as features que o driver vai suportar usando o tipo de dado ErlDrvEntry:
https://github.com/joaohf/epad/blob/0.0.1/src/epad_driver.c 1ErlDrvEntry epad_driver_entry = {
2 .driver_name = "epad_driver",
3 .extended_marker = ERL_DRV_EXTENDED_MARKER,
4 .major_version = ERL_DRV_EXTENDED_MAJOR_VERSION,
5 .minor_version = ERL_DRV_EXTENDED_MINOR_VERSION,
6 .driver_flags = 0,
7 .start = epad_drv_start,
8 .stop = epad_drv_stop,
9 .control = epad_drv_control
10};
11
12DRIVER_INIT(epad_driver) /* must match name in driver_entry */
13{
14 return &epad_driver_entry;
15}
epad_driver é um driver muito simples. Não vamos precisar implementar todas as callbacks (veja aqui a lista completa driver_entry) e a única callback que vamos implementar é a .control:
https://github.com/joaohf/epad/blob/0.0.1/src/epad_driver.c 1static ErlDrvSSizeT epad_drv_control(
2 ErlDrvData data,
3 unsigned int command,
4 char *buf,
5 ErlDrvSizeT buf_len,
6 char **rbuf,
7 ErlDrvSizeT rbuf_len)
8{
9 struct epad_drv_state *state = (struct epad_drv_state *)data;
10 char c[32];
11 unsigned short type;
12 unsigned short code;
13 int val;
14
15 switch (command & EPAD_CMD_MASK) {
16 case EPAD_CMD_EVENT:
17 {
18 memcpy(&c, buf, buf_len);
19 c[buf_len] = '\0';
20 if (sscanf(c, "ev:%hi:%hi:%i", &type, &code, &val) != 3) {
21 goto error_sscanf;
22 }
23
24 if (emit(state->fd, type, code, val)) {
25 driver_failure_posix(state->port, errno);
26 goto error_io;
27 }
28 if (emit(state->fd, EV_SYN, SYN_REPORT, 0)) {
29 driver_failure_posix(state->port, errno);
30 goto error_io;
31 }
32
33 return port_ctl_return_val(EPAD_RES_OK, 0, *rbuf);
34 }
35
36 default:
37 break;
38 }
39
40error_io:
41 return port_ctl_return_val(EPAD_RES_IO_ERROR, 0, *rbuf);
42
43error_sscanf:
44 return port_ctl_return_val(EPAD_RES_ILLEGAL_ARG, 0, *rbuf);
45}
O detalhe desta função está relacionado com o modo como os eventos são enviados do ERTS para o driver pelo processo que faz a interface com o port driver:
https://github.com/joaohf/epad/blob/0.0.1/lib/epad/devices/device.ex1 def handle_cast({:event, type_code_value}, %__MODULE__{port: port} = state) do
2 :ok = event_port_control(port, @epad_cmd_event, type_code_value)
3
4 {:noreply, state}
5 end
No código acima a função event_port_control/3
vai chamar a função
erlang:port_call/3
na qual vai passar uma string contendo o
tipo do evento, código, e valor. Quando este valor for passado (sincronamente)
pela função erlang:portcall/3. Já a função epad_drv_control() vai receber a
string e parsear, obtendo os valores necessários para chamar o restante das
funções e finalmente comunicar com o device _uinput aberto.
Um outro aspecto importante: reparem que a função epad_drv_control()
recebe
char *buf e ErlDrvSizeT buf_len, no caso do epad_driver o valor vai ser uma
string em C. Mas podem haver casos em que seja necessário passar um
Erlang termErlang term é uma unidade na qual expressa um valor em Erlang. Termos podem ser formados por tipos básicos ou complexos.
, aí vamos
precisar utilizar uma biblioteca chamada
Erlang_Interface
para converter para estruturas em C e obter os valores corretos.
Enfim, falar de driver é sempre um assunto denso. Existem muitos exemplos de como fazer e com suporte da documentação oficial fica menos complicado mas ainda sim é necessário avaliar se não existe algum modo de implementar determinada feature sem utilizar C/C++ fora do contexto do ERTS.
Phoenix channels
Phoenix Channels implementa uma abstração em cima de mensagens websocket nas quais permitem extender o modelo de atores para interfaces e clientes. De fato não é uma novidade pois o assunto já é antigo e bem estabelecido exemplos:
Vi a ideia pela primeira vez neste post: Comet is dead long live websockets e depois projetos como NitrogenProject, N2O e Cowboy começaram a implementar APIs e infraestrutura necessária.
Mas com Elixir e Phoenix, ficou bem interessante pois o conceito e utilização estão bem mais flexíveis e fáceis de usar.
Quando o channel é criado (ou seja, um usuário acessou a interface web) a
callback join/3
é chamada para criar uma processo no qual representa uma
instância do driver epad.
1 def join("epad:gamepad", %{}, socket) do
2 device = Epad.gamepad() |> check_gamepad
3 {:ok, assign(socket, :device, device)}
4 end
Após a inicialização, o channel aguarda eventos vindos do cliente web, chamando
a callback handle_in/3
para tratar os eventos:
1 def handle_in("event", %{"code" => code, "type" => type, "value" => value}, socket) do
2 :ok = Device.send_event(socket.assigns[:device], type, code, value)
3 {:noreply, socket}
4 end
Repare que a função sempre recebe o estado do socket que está na variável
socket
. Assim sabemos exatamente qual usuário estamos tratando.
Quando o usuário sair da interface web, a callback terminate/2
é chamada para
finalizar o usuário criado:
1 def terminate(_reason, socket) do
2 Player.stop(socket.assigns[:player])
3 end
Client javascript e interface web
A parte web foi implementada em javascript. Claro que usei como base o projeto sbidolach/mobile-gamepad e fiz algumas adaptações principalmente removendo o suporte ao socket.io e usando phoenix channels.
Como não sou especialista em interfaces web, fiz o mínimo necessário para poder apertar um botão e o evento ser enviado para o backend epad.
Seguem alguns detalhes interessantes.
Todos os artefatos web estão no diretório assets. Por padrão Phoenix Framework cria uma estrutura de projeto baseada no webpack. Em projetos puramente web, essa estrutura é bem conhecida e creio que facilita o uso.
O arquivo package.json está preparado para importar duas dependências instaladas pelo Phoenix:
https://github.com/joaohf/epad/blob/0.0.1/assets/package.json1 "dependencies": {
2 "jquery": "^3.5.1",
3 "nipplejs": "^0.8.5",
4 "phoenix": "file:../deps/phoenix",
5 "phoenix_html": "file:../deps/phoenix_html"
6 },
phoenix é uma pequena biblioteca em javascript no qual abstrai e exporta uma API para envio e recebimento de mensagens websocket.
A implementação do cliente gamepad foi feita no arquivo assets/js/gamepad.js com ajuda da biblioteca jquery e também da biblioteca yoannmoinet/nipplejs , responsável por criar os botões e joystick na tela.
Basicamente quando o usuário aperta algum botão dois eventos são capturados:
touchstart
e touchend
sendo que para cada evento uma mensagem é enviada para
o backend com o código do evento:
1 $(".btn")
2 .off("touchstart touchend")
3 .on("touchstart", function(event) {
4 channel.push("event", {
5 type: 0x01,
6 code: $(this).data("code"),
7 value: 1
8 });
9 $(this).addClass("active");
10 hapticCallback();
11 })
12 .on("touchend", function(event) {
13 channel.push("event", {
14 type: 0x01,
15 code: $(this).data("code"),
16 value: 0
17 });
18 $(this).removeClass("active");
19 });
Desta forma podemos criar qualquer tipo de botão, enviar os eventos e o backend faz a conversão para o device uinput do Linux.
O resto da implementação são funções e tratamentos da interface web.
Release e instalação
Sobre compilar drivers em C/C++
Já sabemos que a aplicação epad utiliza um driver Erlang para acessar o device uinput. Drivers podem ser escritos em C ou C++ e geralmente precisam de um arquivo Makefile com as regras de compilação, flags, includes e bibliotecas adicionais.
No caso do epad, o Makefile é simples, bastando criar uma shared libraryBibliotecas compartilhadas são bibliotecas nas quais são carregadas pelos programas durante a inicialização ou ao longa da execução da aplicação principal. . Mas podem haver casos mais complicados e é por isso que usar a dependência elixir_make faz sentido:
Includindo a dependência aqui:
https://github.com/joaohf/epad/blob/0.0.1/mix.exs1 {:elixir_make, "~> 0.6", runtime: false}
E chamando durante o comando de compilação (mix compile
)
1 compilers: [:elixir_make, :phoenix, :gettext] ++ Mix.compilers(),
Então o Makefile customizado para as necessidades do epad vai ser chamado.
Lembrando que precisamos ter a opção de fazer cross compile pois vamos instalr o epad em um hardware especifico. No Makefile, coloquei algumas redefinições para obter o compilador usando variáveis do ambiente.
Arquivo Makefile https://github.com/joaohf/epad/blob/0.0.1/Makefile1else ifeq ($(UNAME_SYS), Linux)
2 CC ?= gcc
3 CFLAGS ?= -O3 -std=c99 -finline-functions -Wall -Wmissing-prototypes
4 CXXFLAGS ?= -O3 -finline-functions -Wall
5endif
Usando esta técnica posso configurar qualquer toolchainÉ um conjunto de ferramentas organizadas nas quais são usadas para compilação ou execução de aplicações. necessária.
Geração da release
O processo de geração da release é muito simples. Quando usamos Elixir, e a partir da versão 1.9.x, a ferramenta mix já provê mecanismos para geração da release. Bastando adicionar pequenas configurações. Exemplo;
https://github.com/joaohf/epad/blob/0.0.1/mix.exs 1 epad: [
2 applications: [
3 epad: :permanent
4 ],
5 steps: [
6 :assemble, :tar
7 ],
8 include_erts: false
9 ]
10 ],
11 source_url: "https://github.com/joaohf/epad",
12 homepage_url: "https://github.com/joaohf/epad",
Preste atenção que não queremos incluir o ERTS na release gerada. Por dois motivos:
- se incluirmos o ERTS, este provavelmente não vai executar em outra arquitetura
- iremos instalar um ERTS nativo no hardware alvo
Enfim, o processo de geração de release foi documentado aqui https://github.com/joaohf/epad#development
Erlang no hardware alvo
Estou usando uma raspberrypi4 com a distro retropie. A distro possui um gerenciador de pacotes funcional no qual podemos instalar o compilador gcc, baixar as fontes do Erlang/OTP e proceder com a instalação. Esse link descreve todo o processo https://elinux.org/Erlang.
Verificando o funcionamento
Após instalar a aplicação epad e fazer as configurações documentadas aqui: https://github.com/joaohf/epad#installing-and-configuring-epad-on-retropie, podemos instalar algumas ferramentas e verificar se o Linux está recebendo os eventos quando pressionamos algum botão no controle virtual:
- Usar o navegador do celular e acessar o endereço `http://IP:4000'
- Acessar via ssh e listar os input devices, deve haver apenas um device:
sudo lsinput
- Executar a ferramenta input-events:
sudo input-events 0
- No celular, pressionando o botão ‘A’ na interface web o evento vai ser recebido pela ferramenta input-events
Pronto está funcionando. O Linux recebeu o evento com sucesso.
Conclusão
Bom, economizei algum dinheiro implementando o controle virtual. Agora só falta chegar no final do Super Mario e salvar a princesa.