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

Um aplicativo Typescript para controle de câmera robótica

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.

Mecanismo de pan-tilt com servos

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:

Aplicativo para mobile

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

Aplicativo para 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 + "&deg;";
    this.updateAngles();
  }
  verticalChange(): void {
    this.verticalHint.innerHTML = this.rangeVertical.value + "&deg;";
    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:

Onde colar o código HTML gerado

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.

Informações da rede Wi-Fi

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.