Um aplicativo Typescript para controle de câmera robótica
Um pequeno aplicativo de front-end embutido em um microcontrolador para o controle intranet de uma câmera robótica
14/03/2023

Visão Geral
Eu tenho pesquisado microcontroladores ESP32 e suas aplicações em IoT e encontrei uma placa de desenvolvimento interessante com módulo de câmera integrado chamada ESP32-CAM. Este dispositivo de $7 também vem com Bluetooth integrado, WiFi, alguns sensores e um processador dual-core de 32 bits, que é capaz até de executar um servidor web de streaming com recursos básicos de reconhecimento facial. Um verdadeiro sonho para os hobbystas.
A placa também tem alguns pinos de E/S e é capaz de controlar motores com PWM. Isso permitiria controlar o movimento de um robô e transmitir o vídeo de sua câmera no mesmo dispositivo. Daí veio a ideia de conectar o ESP32-CAM a um mecanismo de pan-tilt, que permite controlar a câmera tanto vertical quanto horizontalmente por meio de dois servo motores.

Em seguida, desenvolvi um servidor web para ser incorporado ao chip, que transmitiria o vídeo da câmera e forneceria um aplicativo web para controlar os motores.
Comecei modificando o código do exemplo CameraWebServer fornecido pela Espressif (empresa que desenvolve o ESP32), que hospeda uma página HTML contendo um stream de vídeo da câmera e muitas configurações de imagem. Minhas modificações no código do firmware incluíram alguns endpoints para configurar o streaming e controlar os motores.
Eu também precisava prover uma página HTML personalizada com controles adequados para posicionar a câmera - a parte front-end do meu projeto -, que deveria ser leve, pois a memória disponível no chip é limitada (logo, usar um framework/biblioteca JavaScript como React não seria viável). Em seguida, decidi iniciar um projeto separado usando TypeScript puro e simples, para que eu poder gerenciar um código bem organizado que pudesse ser compilado em um aplicativo JavaScript leve.
No vídeo abaixo, você pode ver o conjunto de câmera robótica funcionando na minha mesa, enquanto envio comandos de controle através do aplicativo web hospedado no próprio chip ESP32-CAM.
Como o aplicativo funciona
Antes de mergulhar no código, vamos primeiro dar uma olhada na interface de usuário do projeto e suas funcionalidades. A imagem abaixo mostra o layout do aplicativo em dispositivos móveis:

E aqui está a aparência do aplicativo em um ambiente desktop:

A interface do usuário é dividida em quatro seções: viewport de streaming, controle da câmera, navegação e configuração. A seção de viewport de streaming exibe o vídeo capturado pela câmera e transmitido pelo servidor. A seção de controle da câmera é responsável por controlar a posição da câmera e o estado do LED de flash.
Se a estrutura pan-tilt for construída em um robô móvel, a seção de navegação pode ser usada para controlar seu movimento (caso contrário, você pode simplesmente colapsar a seção clicando no ícone do canto superior direito). A seção de configuração permite definir a resolução da câmera e inserir o IP do dispositivo na rede, caso você não esteja acessando o aplicativo front-end diretamente do servidor ESP32-CAM.
Estrutura do projeto front-end
O projeto consiste em um único arquivo HTML, alguns arquivos de stylesheet SASS,
e classes TypeScript para manipular a lógica do aplicativo.
O arquivo index.html está localizado na pasta public
, que também inclui diretórios para os
arquivos .js e .css gerados na compilação.
A pasta src/app
inclui os arquivos fonte TypeScript e a pasta src/styles
contém os arquivos .scss.
📦navi-cam
┣ 📂public
┃ ┣ 📂assets
┃ ┃ ┣ 📂css
┃ ┃ ┣ 📂img
┃ ┃ ┗ 📂js
┃ ┗ 📜index.html
┣ 📂src
┃ ┣ 📂app
┃ ┗ 📂styles
┣ 📜gulpfile.js
┣ 📜package-lock.json
┣ 📜package.json
┗ 📜tsconfig.json
Compilando e executando o aplicativo
Alguns scripts NPM são usados para ajudar no processo de desenvolvimento e compilação:
"scripts": {
"test": "echo \"Erro: nenhum teste especificado\" && exit 1",
"tswatch": "tsc -w",
"scsswatch": "sass --watch src/styles:public/assets/css --style compressed --no-source-map",
"start": "concurrently \"npm run tswatch\" \"npm run scsswatch\"",
"build": "tsc && sass src/styles:public/assets/css --style compressed --no-source-map",
"dist": "tsc && gulp"
},
A tarefa start usa a biblioteca concurrently para monitorar os arquivos .scss e .ts, além de gerar a partir deles os arquivos public/assets/css/style.css e public/assets/js/index.js, respectivamente. Ambos os arquivos são incluídos no public/index.html, que pode ser acessado durante o processo de desenvolvimento utilizando um servidor local.
A tarefa build apenas gera os arquivos como antes, mas sem monitorar continuamente os arquivos fonte. Finalmente, a tarefa dist compila o projeto e usa um script gulp para empacotar todos os estilos e scripts em um único arquivo HTML, tornando mais fácil a incorporação ao firmware do servidor ESP32-CAM.
Contêiner de aplicação
O arquivo src/app/app.ts
é responsável por instanciar services e handlers. Ele também seleciona elementos DOM do public/index.html
e os injeta nos handlers, para que eles possam tratar corretamente os eventos do usuário.
Tratamento de eventos
Cada seção do aplicativo é controlada por uma classe de handler correspondente, as quais estão localizadas no diretório src/app/handler
. Como exemplo, vamos dar uma olhada na classe CameraHandler
:
import { CameraService } from "../service/camera.service";
export class CameraHandler {
private horizontalHint: HTMLElement;
private verticalHint: HTMLElement;
constructor(
private cameraService: CameraService,
private rangeHorizontal: HTMLInputElement,
private rangeVertical: HTMLInputElement,
private checkFlashlight: HTMLInputElement
) {
this.rangeHorizontal.addEventListener("change", (event) =>
this.horizontalChange()
);
this.rangeVertical.addEventListener("change", (event) =>
this.verticalChange()
);
this.checkFlashlight.addEventListener("change", (event) =>
this.flashlightChange()
);
this.horizontalHint = <HTMLElement>(
this.rangeHorizontal.nextSibling?.nextSibling
);
this.verticalHint = <HTMLElement>(
this.rangeVertical.nextSibling?.nextSibling
);
}
flashlightChange(): void {
this.cameraService.setFlashlight(this.checkFlashlight.checked);
}
horizontalChange(): void {
this.horizontalHint.innerHTML = this.rangeHorizontal.value + "°";
this.updateAngles();
}
verticalChange(): void {
this.verticalHint.innerHTML = this.rangeVertical.value + "°";
this.updateAngles();
}
updateAngles(): void {
this.cameraService
.setAngle(
parseInt(this.rangeHorizontal.value) + 90,
parseInt(this.rangeVertical.value) + 90
)
.then(
(response) => console.log(response),
(error) => console.error(error)
);
}
}
Como mostrado acima, três elementos de entrada e um CameraService
são injetados na classe. O handler adiciona listeners
de eventos onchange aos controles de range (que definem os ângulos da câmera) e à caixa de seleção do flash. Esses métodos de gerenciamento de eventos invocam os métodos do CameraService
para enviar comandos para o servidor ESP32 à medida que os valores de entrada mudam.
Enviando requisições para o servidor ESP32
As classes de serviço são responsáveis por se comunicar com o servidor ESP32, enviando comandos sempre que os handlers detectam ações do usuário. Todos eles usam uma classe chamada HttpService
, que implementa um método que encapsula a criação e execução de uma requisição XMLHttpRequest (essa é old school!). Vamos dar uma olhada no código CameraService
, que fornece métodos para atualizar os ângulos da câmera e para ligar/desligar a luz de flash:
import { HttpService } from "./http.service";
export class CameraService {
constructor(private httpService: HttpService) {}
public setAngle(
horizontalAngle: number,
verticalAngle: number
): Promise<string> {
return this.httpService.get<string>(
`/servo?hor=${horizontalAngle}&ver=${verticalAngle}`
);
}
public setFlashlight(on: boolean): Promise<string> {
return this.httpService.get<string>(`/flash?value=${on ? 1 : 0}`);
}
}
Os endpoints /servo
e /flash
são definidos no firmware do servidor, sendo responsáveis por atualizar os ângulos dos servos e alternar a luz de flash, respectivamente.
Exibindo o streaming de vídeo da câmera
O streaming de vídeo pode ser acessado pelo endpoint /stream
provido pelo servidor ESP32 na porta 81. Em nosso aplicativo front-end, o stream de vídeo é mostrado em um elemento HTML <img>
. Isso é realizado definindo sua propriedade src
como a URL do stream na classe StreamingHandler
:
import { env } from "../env";
export class StreamingHandler {
constructor(private viewport: HTMLImageElement, private waitingElement: HTMLElement) { ... }
setupVideoStreaming() {
console.log('Localização de origem: ' + location.origin);
const streamingUrl = (env.alternative_base_uri || location.origin) + ':' + env.streaming_port + env.streaming_uri;
this.viewport.src = streamingUrl;
this.viewport.style.display = 'block';
this.waitingElement.style.display = 'none';
console.log('URL de streaming: ' + streamingUrl);
}
}
Implantando o aplicativo front-end
O projeto de firmware é escrito em C++ e compilado utilizando a IDE do Arduino.
O programa configura um servidor HTTP que provê uma página index.html
, alguns endpoints de controle e um stream de vídeo.
O código-fonte da página index.html
deve ser gerado pelo projeto TypeScript descrito anteriormente e colado no arquivo de cabeçalho index_html_content.h
, como mostrado abaixo:
Se você tiver o gulp instalado, executar npm run dist
faz com que todos os códigos CSS e JavaScript sejam compilados e incluídos no arquivo resultante dist/index.html
. Caso contrário, você pode executar npm run build
para que a compilação do CSS e do JavaScript ocorra e, em seguida, embutir manualmente os arquivos resultantes .css e .js da pasta public/assets
em uma cópia do arquivo public/index.html
.
O tamanho da página HTML gerada provavelmente será inferior a 40 kB.
Depois de colar o código HTML resultante no código-fonte do firmware, você deve inserir suas configurações de rede Wi-Fi no arquivo principal esp32-cam-navi-firmware.ino
e compilar/carregar o firmware no dispositivo ESP32 usando a IDE do Arduino.
Conclusão
Neste artigo, mostrei como estruturar um projeto TypeScript puro e simples para criar uma UI web para controlar uma câmera robótica usando o ESP32-CAM e dois servo motores. Usar o TypeScript me permitiu organizar o gerenciamento de múltiplos eventos em classes separadas e com type-safety. Todo o processo resultou em uma página web leve o suficiente para que um pequeno SoC como o ESP32 pudesse hospedá-la com facilidade. O código-fonte do aplicativo pode ser encontrado no meu GitHub, assim como o código de firmware ESP32 utilizado neste projeto.