Controlar un Dispositivo IoT desde un Teléfono Móvil por MQTT con ESP32 Cam y Cámara OV2640

Actualizado: 6 enero 2023

Este artículo sobre IoT te enseñará cómo controlar un chip wifi desde un teléfono móvil usando el protocolo MQTT.

Vamos a crear una app móvil que saca una foto en un dispositivo remoto y la presenta en la pantalla del móvil; pero la puedes modificar para hacer otras cosas (como por ejemplo, encender una luz, abrir una puerta, controlar un robot, responder a la alerta de un sensor etc).

El dispositivo remoto será una placa AI Thinker con una cámara OV2640.

El ejemplo es una aplicación simple de Android creada en Java (aunque se puede conseguir el mismo resultado con otros lenguajes y plataformas).

Ten en cuenta que ésta es una demostración minimalista. Hay muchas variantes y optimizaciones que se pueden aplicar a este proyecto.

Materiales

  • Placa ESP32 Cam AI Thinker (con la cámara OV2640)
  • Adapador USB/TTL serial (conocido también como "programador FTDI" por alguna razón)
  • cables puente (hembra-hembra X 5)
  • Cable alargador USB tipo A macho-hembra (opcional pero es más conveniente que conectar el adaptador USB/TTL directamente al puerto del PC)
  • Acceso a un broker MQTT (internet o local)
  • Acceso de lectura/escritura a un servidor FTP (internet o local)

Resumen de la Lógica

Arquitectura

El proceso se trata de 2 topics (mira este artículo para saber como montar esto). El chip ESP32 Cam suscribe al tópico sacar foto y el móvil suscribe al tópico foto tomada para recibir las notificaciones desde el chip.

El ESP32 envia las fotos al servidor FTP y el móvil las descarga desde allí. El ESP32 necesita permisos de lectura/escritura. El móvil no necesita permisos de FTP para este ejemplo sencillo; descargará las fotos por HTTP.

El servidor de tiempo es opcional pero ideal. Proporciona un timestamp la fecha y hora al ESP32 con el fin generar un nombre de fichero único y significante para cada foto (recuerda que, a diferencia de tu PC o Mac, el ESP32 no tiene ni idea de que día es, ni la hora). Incluir la fecha y hora en los nombres de ficheros también lo hace posible ordenar y mostrar tus fotos en orden cronológico.

IoT photo over MQTT architecture
Arquitectura foto IoT con MQTT

Flujo de Trabajo MQTT

Móvil publica al tópico "take photo"; ESP32 recibe el mensaje

MQTT sacar foto
MQTT sacar foto

ESP32 recibe fecha/hora para crear el nombre de fichero

Timestamp del servidor de tiempo real
Timestamp del servidor de tiempo real

ESP32 saca una foto, genera jpeg y lo envia al servidor FTP

ESP32 envia foto a servidor FTP
ESP32 envia foto a servidor FTP

ESP32 publica notificación al tópico "photo taken"; el móvil recibe notificación

Notificación desde tópico MQTT
Notificación desde tópico MQTT

Móvil descarga foto por HTTP

Descarga de foto al móvil (por HTTP)
Descarga de foto al móvil (por HTTP)

Pasos

Configurar Broker MQTT para Comunicación IoT

La configuración del broker MQTT se explica aquí.

Crear Carpeta en el Servidor FTP para la Subida de Fotos

El chip ESP32 necesita permisos de escritura; el móvil no los necesita en este ejemplo porque descargará las fotos por HTTP.

Montar la Cámara OV2640 en la Placa AI Thinker ESP32 Cam

  1. Abre el conectador FPCFlexible printed circuit de la placa AI Thinker levantandolo suavemente con tu dedo (es más fácil que parece!).
    Cómo abrir el conectador FPC de cámara de la placa AI Thinker
    Cómo abrir el conectador FPC de cámara de la placa AI Thinker
  2. Coloca la cámara OV2640 en la ranura del FPC (que también es más fácil que parece!).
    Cómo montar la cámara OV2640 camera en una placa AI Thinker
    Cómo montar la cámara OV2640 camera en una placa AI Thinker
  3. Cierra el conector FPC de la placa AI Thinker apretando suavemente.
    Cómo cerrar el conector FPC de cámara de la placa AI Thinker
    Cómo cerrar el conector FPC de cámara de la placa AI Thinker
  4. ¡No olvides quitar el plástico del lente de la cámara OV2640 si es nueva!

Conectar el adaptador USB-TTL y la placa AI Thinker para subir el código

Conecta el adaptador USB/TTL a tu PC mediante un cable alargador USB tipo A (también podrías dejar el adaptor colgado directamente desde un puerto del PC pero esto no es tan conveniente y restringía bastante la vista de la cámara); luego conecta el adaptador USB/TTL a la placa ESP32.

A continuación el mapeo pin-a-pin:

Adaptador USB/TTL Placa AI Thinker/ESP32-CAM
5v 5v
GND GND
TX U0R
RX U0T
  Io0 - GND *

* Es necesario hacer cortacircuito entre pin GPIO 0 y GNDmasa (ground para los estadounidenses, earth para los británicos) durante la subida (no cuando tu código se está ejecutando). También puedes conectar GPIO 0 al pin GND libre del adaptador USB/TTL si te va mejor.

Esquemático

Cómo conectar el adaptador USB/TTL (o "programador FTDI") al ESP32 Cam
Cómo conectar el adaptador USB/TTL (o "programador FTDI") al ESP32 Cam

IMPORTANTE - Comprueba las etiquetas de tu adaptador USB/TTL. El orden no es universal y podría ser tranquilamente diferente al modelo que se ve aquí. Asegúrate de identificar 5v, GND, RX y TX correctamente.

Ahora deberías tener algo como esto (fíjate en el hilo blanco haciendo cortocircuito entre GPIO0 y GND):

USB/TTL adapter (or "FTDI programmer") connected to ESP32 Cam
Adaptador USB/TTL (o "programador FTDI") conectado al ESP32 Cam

Código para el ESP32

Arranca el IDE de Arduino, crea un programallamado sketch en inglés nuevo, y añade el código. Lo repasaremos paso a paso (el código completo se ve más abajo en esta página):

Incluye las librerías siguientes. Si falta alguna, simplemente haz una búsqueda en Tools > Manage Libraries y hacer clic en Install. En el caso de que la librería no esté disponible en el gestor de librerías, haz una búsqueda por internet, descarga el zip, descomprimirlo en tu carpeta de libraries de Arduino y reininiar el IDE de Arduino.

#include "esp_camera.h"
#include "soc/soc.h"            // Necesario para gestionar bajones de luz (que vamos a deshabilitar)
#include "soc/rtc_cntl_reg.h"   // Necesario para gestionar bajones de luz (que vamos a deshabilitar)
#include "driver/rtc_io.h"
#include <WiFi.h>
#include "ESP32_FTPClient.h"
#include <WiFiClientSecure.h>
#include <PubSubClient.h>      // MQTT lib de knolleary (https://github.com/knolleary/pubsubclient)
#include <NTPClient.h>
#include <WiFiUdp.h>
#include "time.h"
#include "ArduinoJson.h"  // JSON lib de Benoit Blanchon (https://www.arduinolibraries.info/libraries/arduino-json)

Añade los parámetros de conexión de tu servidor FTP y la ruta a la carpeta que usaremos para subir las fotos.

/* Parametros conexión FTP */
char* FTP_URL = "ftp.tu.servidorftp";
char* FTP_USER = "tu@usuarioftp";
char* FTP_PWD = "tu contraseña FTP";
char* FTP_PATH = "/ruta/a/carpeta/de/fotos/";

/* Parámetros conexión HTTP (para suscritores puedan descargar las imagenes) */
char* HTTP_PATH = "https://URL/and/path/to/photos/folder/";

Añade los parámetros del broker MQTT incluidos los nombres de tópico y un prefijo para el identificador de conexión. Este ejemplo usa Hive MQTT (echa un vistazo a este artículo si quieres saber montar esto) pero puedes usar otro broker si prefieres.

  /* Parámetros conexión Hive MQTT*/
  const char* MQTT_SERVER = "xxxxxxxxxxxxxxxxxxxxxx.s1.eu.hivemq.cloud";
  const char* MQTT_USERNAME = "Tu usuario HIVE MQTT";
  const char* MQTT_PWD = "Tu contraseña HIVE MQTT";
  const int MQTT_PORT = 8883;
  const char* MQTT_PUB_TOPIC = "yourtopics/photoTaken";  // Publicaremos notificaciones a este tópico cada vez saquemos un foto
  const char* MQTT_SUB_TOPIC = "yourtopics/takePhoto";   // Recibiremos los comandos de sacar una foto desde este tópico
  const char* MQTT_CLIENT_ID_PREFIX = "el.prefijo.que quieras.";
  const int QOS = 1;

Añade las llaves que usaremos en la payload (carga útil) de JSON en nuestros mensajes MQTT y un identificador único para nuestro ESP32 Cam. Éstos son opcionales pero nos permitirán usar multiples cámaras y móviles. Ten en cuenta que tendrás que hardcodear el valor SENSOR_ID único para cada ESP32 que montes. Dale un nombre significativo ("cocina", "jardin", "gatera" etc). En este ejemplo se llama "musicRoom" ("sala de música").

/* Llaves JSON  */
const char* SENSOR_ID_KEY = "sensorId";          // The key in the JSON payload for the sensor ID (used for publishing and receiving messages)
const char* REQUESTOR_ID_KEY = "requestorId";    // The key in the JSON payload for the requestor device ID (used for publishing and receiving messages)
const char* PHOTO_URL_KEY = "photo";             // The key in the JSON payload for the URL of uploaded photos

/* El identificador de este ESP32 cam (no haremos caso a mensajes que incluyan este identificador) */
const char* SENSOR_ID = "musicRoom";

Añade los parámetros de tu wifi:

/* Parámetros conexión wifi */
const char* SSID = "your wifi SSID";
const char* WIFI_PWD = "your wifi password";

Constantes para la nomenclatura de ficheros:

/* Nomenclatura de ficheros */
const char* FILE_EXTENSION = ".jpg";            // extension (always JPEG)
const String FILENAME_SANITISER = "-";          // to delimit parts of filenames

El certificado raiz para establecer una conexión segura a Hive MQTT (si usas otro broker MQTT, no necesitas esto).

/* Certificado raiz de Hive MQTT  */
static const char* ROOT_CA PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
)EOF";

Define los pines del AI Thinkerº para conectar a la cámara OV2640 y declara el objeto de configuración de la OV2640.

  /* Estos valores son para CAMERA_MODEL_AI_THINKER (copiados desde camera_pins.h del ejemplo de Arduino CameraWebServer) */
  #define PWDN_GPIO_NUM     32
  #define RESET_GPIO_NUM    -1
  #define XCLK_GPIO_NUM      0
  #define SIOD_GPIO_NUM     26
  #define SIOC_GPIO_NUM     27

  #define Y9_GPIO_NUM       35
  #define Y8_GPIO_NUM       34
  #define Y7_GPIO_NUM       39
  #define Y6_GPIO_NUM       36
  #define Y5_GPIO_NUM       21
  #define Y4_GPIO_NUM       19
  #define Y3_GPIO_NUM       18
  #define Y2_GPIO_NUM        5
  #define VSYNC_GPIO_NUM    25
  #define HREF_GPIO_NUM     23
  #define PCLK_GPIO_NUM     22

  camera_config_t cameraCfg;     // Configuración de cámara (asignaremos los parámetros anteriores a esto)

Parámetros de la conexión NTPnetwork time protocol. Solo necesitamos esto para nombrar los ficheros que vayamos a subir. Aquí usamos la librería WiFiUDP con su configuración por defecto.

Parámetro Valor
servidor pool.ntp.org
franja horaria 0
(es decir, UTC)
frecuencia de actualización 60000
(es decir, cada minuto)

Se puede parameterizar la configuración más si quieres (más información en el repositorio github de NTPClient). Por ejemplo:

NTPClient ntpClient(ntpUDP, "europe.pool.ntp.org", (3600*2), 30000);

NTPClient ntpClient(ntpUDP);

Inicia la sesión FTP:

ESP32_FTPClient ftp (FTP_URL, FTP_USER, FTP_PWD, 5000, 2);

Inicia los clientes wifi y MQTT:

WiFiClientSecure espClient;
PubSubClient mqttClient(espClient);

Añade una función para arrancar la conexión wifi. Invocaremos esta función de la función setup().

void setupWifi() {
  delay(10);
  Serial.print("\nConnecting to ");
  Serial.println(SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(SSID, WIFI_PWD);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.print("\nWiFi connected\nIP address: ");
  Serial.println(WiFi.localIP());
}

Añade una función para arrancar la cámara OV2649, que también llamaremos desde la función setup().

void setupCamera() {
  cameraCfg.ledc_channel = LEDC_CHANNEL_0;
  cameraCfg.ledc_timer = LEDC_TIMER_0;
  cameraCfg.pin_d0 = Y2_GPIO_NUM;
  cameraCfg.pin_d1 = Y3_GPIO_NUM;
  cameraCfg.pin_d2 = Y4_GPIO_NUM;
  cameraCfg.pin_d3 = Y5_GPIO_NUM;
  cameraCfg.pin_d4 = Y6_GPIO_NUM;
  cameraCfg.pin_d5 = Y7_GPIO_NUM;
  cameraCfg.pin_d6 = Y8_GPIO_NUM;
  cameraCfg.pin_d7 = Y9_GPIO_NUM;
  cameraCfg.pin_xclk = XCLK_GPIO_NUM;
  cameraCfg.pin_pclk = PCLK_GPIO_NUM;
  cameraCfg.pin_vsync = VSYNC_GPIO_NUM;
  cameraCfg.pin_href = HREF_GPIO_NUM;
  cameraCfg.pin_sscb_sda = SIOD_GPIO_NUM;
  cameraCfg.pin_sscb_scl = SIOC_GPIO_NUM;
  cameraCfg.pin_pwdn = PWDN_GPIO_NUM;
  cameraCfg.pin_reset = RESET_GPIO_NUM;
  cameraCfg.xclk_freq_hz = 20000000;
  cameraCfg.pixel_format = PIXFORMAT_JPEG;

  // Configurar dimensiones y calidad de imagen modestas para ahorra memoria, ancho de banda, espacio en el servidor y accelerar el proceso (suficiente para esta demostración)
  cameraCfg.frame_size = FRAMESIZE_SVGA;
  cameraCfg.jpeg_quality = 12;
  cameraCfg.fb_count = 1;

  // Arrancar cámara
  esp_err_t status = esp_camera_init(&cameraCfg);
  if (status != ESP_OK) {
    Serial.printf("Could not start camera: error code 0x%x", status);
    return;
  }
}

Añade una función para conectar al broker MQTT, que también se invocará desde la función setup():

void connectMQTT() {
  randomSeed(micros());
  Serial.println("Checking MQTT connection...");
  while (!mqttClient.connected()) {
    Serial.println("Connecting to MQTT broker...");
    String clientId = MQTT_CLIENT_ID_PREFIX + String(random(0xffff), HEX);
    if (mqttClient.connect(clientId.c_str(), MQTT_USERNAME, MQTT_PWD)) {
      Serial.println("MQTT connected");
      mqttClient.subscribe(MQTT_SUB_TOPIC, QOS);
    } else {
      Serial.println("Error connecting to " + mqttClient.state());
      delay(5000);
    }
  }
}

Crea una retrollamada (o "callback") que se invocará por el objeto PubSub cada vez que otro dispositivo publique un mensaje MQTT al tópico takePhoto. El código deserializa el mensaje JSON recibido, y extrae el identificador del ESP32 y el identificar del móvil que solicitó la foto. Si el valor de SENSOR_ID_KEY en el cuerpo del JSON corresponde a nuestro SENSOR_ID, sacaremos y subiremos una foto. Incluiremos el identificador del móvil en la notificación al tópico photoTaken para que cada móvil suscrito al tópico sepa si ha solicitado la foto. Esto es redundante, claro, si solo hay un solo móvil en tu proyecto. La llamada callback() también actualiza la timestamp (marca temporal) del servidor NTP e inicia la captura de foto y su subida por FTP:

void callback(char* topic, byte* payload, unsigned int length){
  Serial.println("callback() invoked");

  StaticJsonDocument<200> json;

  DeserializationError jsonError = deserializeJson(json, payload);

  if (jsonError) {
    Serial.print(F("deserializeJson() failed: "));
    Serial.println(jsonError.f_str());
    return;
  }

  const char* sensorId = json[SENSOR_ID_KEY];
  const char* requestorId = json[REQUESTOR_ID_KEY];

  Serial.print("sensor: ");
  Serial.println(sensorId);

  Serial.print("requestor ID: ");
  Serial.println(requestorId);

  ntpClient.update();
  camera_fb_t * fb = takePhoto();
  uploadPhoto(fb, requestorId);
  esp_camera_fb_return(fb);

  Serial.println("Photo taken");
}

Crea una función que saca una foto y que devuelve los datos de la imagen en formato JPEG. Esta función será invocada desde la función callback().

  /* Sacar un foto y devolver los datos de imagen como objeto tipo Frame Buffer*/
camera_fb_t * takePhoto() {
  camera_fb_t * fb = NULL;
  fb = esp_camera_fb_get();
  if(!fb) {
    Serial.println("Couldn't take photo!!!!");
  }
  return fb;
}

Crea un método para nombrar el fichero JPEG, subirlo al servidor y publicar la notificación al tópico photoTaken. La payload del mensaje contendrá el identificador de la ESP32 Cam, el identificador del móvil que solicitó la foto y el URL HTTP del fichero que se ha subido. Ten en cuenta que puedes modificar el código de Android para descargar las imagenes por FTP si prefieres.

void uploadPhoto(camera_fb_t * fb, const char* requestorId){
  Serial.println("Uploading image by FTP...");
  Serial.println("Starting FTP session..");
  ftp.OpenConnection();
  Serial.println("FTP session started");
  ftp.ChangeWorkDir(FTP_PATH);
  ftp.InitFile("Type I");
  String filename = SENSOR_ID +  FILENAME_SANITISER + ntpClient.getFormattedDate() + FILE_EXTENSION;
  int l = filename.length() + 1;
  char dst[l];
  filename.toCharArray(dst, l);
  Serial.print("File name: ");
  Serial.println(dst) ;
  ftp.NewFile(dst);
  ftp.WriteData(fb->buf, fb->len);
  ftp.CloseFile();
  ftp.CloseConnection();

  if (!mqttClient.connected()) {
    Serial.println("MQTT not connected. Connecting now...");
    connectMQTT();         // Conectar al broker MQTT si no estamos conectados
  }
  else{
    Serial.println("MQTT already connected");
  }
  Serial.print("Publishing message: ");
  Serial.print(dst);
  Serial.print(" to topic: ");
  Serial.print(MQTT_PUB_TOPIC);
  Serial.println("...");

  char httpURL[strlen(HTTP_PATH) + strlen(dst)];     // objeto array para almacenar el URL HTTP completo a la imagen que acabamos de subir

  strcpy(httpURL, HTTP_PATH);                        // añadimos el dominio al URL
  strcat(httpURL, dst);                              // añadimos la ruta al URL

  StaticJsonDocument<200> json;
  json[SENSOR_ID_KEY] = SENSOR_ID;
  json[REQUESTOR_ID_KEY] = requestorId;
  json[PHOTO_URL_KEY] = httpURL;

  int payloadSize = measureJson(json) + 1;
  char payload[payloadSize];
  serializeJson(json, payload, sizeof(payload));

  mqttClient.publish(MQTT_PUB_TOPIC, payload, true);
  Serial.println("Message published");
}

Crea el código para el método setup() de la ESP32 Cam (que es el método que se ejecuta cada vez que se reinicia el chip). En este proyecto inicia la comunicación serie (para poder depurar nuestro código), inicia la cámara y las conexiones wifi, NTP y MQTT.

También deshabilita la detección de bajones de corriente para que el chip no se reinicie con cada bajada de voltaje.

void setup() {
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);           // deshabilitar detección de bajones de corriente

  Serial.begin(115200);

  setupWifi();
  setupCamera();
  ntpClient.begin();

  espClient.setCACert(ROOT_CA);                        // informar el certificado para la conexión MQTT
  mqttClient.setServer(MQTT_SERVER, MQTT_PORT);        // informar los parámetros del servidor
  mqttClient.setCallback(callback);                    // asignar la función "callback" (retrollamada) que se ejuctará cada vez que recibamos un mensaje MQTT
}

La función loop() (que se ejecutará continuamente) asegura que el ESP32 esté siempre conectado al broker MQTT y que la sesión MQTT noo caduque.

void loop() {
  if (!mqttClient.connected()) {
    connectMQTT();
  }
  mqttClient.loop();                                 // invocar la llamada "keepalive" del cliente MQTT
}

ESP32 Cam - código completo

  #include "esp_camera.h"
  #include "soc/soc.h"            // Necesario para gestionar bajones de luz (que vamos a deshabilitar)
  #include "soc/rtc_cntl_reg.h"   // Necesario para gestionar bajones de luz (que vamos a deshabilitar)
  #include "driver/rtc_io.h"
  #include <WiFi.h>
  #include "ESP32_FTPClient.h"
  #include <WiFiClientSecure.h>
  #include <PubSubClient.h>      // MQTT lib de knolleary (https://github.com/knolleary/pubsubclient)
  #include <NTPClient.h>
  #include <WiFiUdp.h>
  #include "time.h"
  #include "ArduinoJson.h"  // JSON lib de Benoit Blanchon (https://www.arduinolibraries.info/libraries/arduino-json)

  /* Parametros conexión FTP */
  char* FTP_URL = "ftp.tu.servidorftp";
  char* FTP_USER = "tu@usuarioftp";
  char* FTP_PWD = "tu contraseña FTP";
  char* FTP_PATH = "/ruta/a/carpeta/de/fotos/";

  /* Parámetros conexión HTTP (para suscritores puedan descargar las imagenes) */
  char* HTTP_PATH = "https://URL/and/path/to/photos/folder/";

  /* Parámetros conexión Hive MQTT*/
  /* Parámetros conexión Hive MQTT*/
  const char* MQTT_SERVER = "xxxxxxxxxxxxxxxxxxxxxx.s1.eu.hivemq.cloud";
  const char* MQTT_USERNAME = "Tu usuario HIVE MQTT";
  const char* MQTT_PWD = "Tu contraseña HIVE MQTT";
  const int MQTT_PORT = 8883;
  const char* MQTT_PUB_TOPIC = "yourtopics/photoTaken";  // Publicaremos notificaciones a este tópico cada vez saquemos un foto
  const char* MQTT_SUB_TOPIC = "yourtopics/takePhoto";   // Recibiremos los comandos de sacar una foto desde este tópico
  const char* MQTT_CLIENT_ID_PREFIX = "el.prefijo.que quieras.";
  const int QOS = 1;

  /* Llaves JSON  */
  const char* SENSOR_ID_KEY = "sensorId";          // The key in the JSON payload for the sensor ID (used for publishing and receiving messages)
  const char* REQUESTOR_ID_KEY = "requestorId";    // The key in the JSON payload for the requestor device ID (used for publishing and receiving messages)
  const char* PHOTO_URL_KEY = "photo";             // The key in the JSON payload for the URL of uploaded photos

  /* El identificador de este ESP32 cam (no haremos caso a mensajes que incluyan este identificador) */
  const char* SENSOR_ID = "musicRoom";

  /* Parámetros conexión wifi */
  const char* SSID = "tu SSID wifi";
  const char* WIFI_PWD = "tu contrseña wifi";

  /* Nomenclatura de ficheros */
  const char* FILE_EXTENSION = ".jpg";            // extensión (siempre JPEG)
  const String FILENAME_SANITISER = "-";          // para nombrar los ficheros

  /* Certificado raiz de Hive MQTT  */
  static const char* ROOT_CA PROGMEM = R"EOF(
  -----BEGIN CERTIFICATE-----
  MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
  TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
  cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
  WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
  ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
  MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
  h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
  0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
  A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
  T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
  B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
  B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
  KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
  OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
  jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
  qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
  rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
  HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
  hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
  ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
  3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
  NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
  ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
  TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
  jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
  oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
  4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
  mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
  emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
  -----END CERTIFICATE-----
  )EOF";

  /* Estos valores son para CAMERA_MODEL_AI_THINKER (copiados desde camera_pins.h del ejemplo de Arduino CameraWebServer) */
  #define PWDN_GPIO_NUM     32
  #define RESET_GPIO_NUM    -1
  #define XCLK_GPIO_NUM      0
  #define SIOD_GPIO_NUM     26
  #define SIOC_GPIO_NUM     27

  #define Y9_GPIO_NUM       35
  #define Y8_GPIO_NUM       34
  #define Y7_GPIO_NUM       39
  #define Y6_GPIO_NUM       36
  #define Y5_GPIO_NUM       21
  #define Y4_GPIO_NUM       19
  #define Y3_GPIO_NUM       18
  #define Y2_GPIO_NUM        5
  #define VSYNC_GPIO_NUM    25
  #define HREF_GPIO_NUM     23
  #define PCLK_GPIO_NUM     22

  camera_config_t cameraCfg;     // Configuración de cámara (asignaremos los parámetros anteriores a esto)

  /* Parámetros NTP (network time protocol)
   -  solo necesitamos esto para nombrar los ficheros que vamos a subir */
  WiFiUDP ntpUDP;
  NTPClient ntpClient(ntpUDP);      // valores por defecto: server: pool.ntp.org; timezone offset: 0 (es decir, UTC); update interval: 60000
  // ejemplo parámeterizado: NTPClient ntpClient(ntpUDP, "europe.pool.ntp.org", (3600*2), 30000);

  // Arrancamos sesión FTP
  ESP32_FTPClient ftp (FTP_URL, FTP_USER, FTP_PWD, 5000, 2);

  WiFiClientSecure espClient;
  PubSubClient mqttClient(espClient);    // API disponible aquí: https://pubsubclient.knolleary.net/api#subscribe

  void setupWifi() {
    delay(10);
    Serial.print("\nConnecting to ");
    Serial.println(SSID);

    WiFi.mode(WIFI_STA);
    WiFi.begin(SSID, WIFI_PWD);

    while (WiFi.status() != WL_CONNECTED) {
      delay(500);
      Serial.print(".");
    }
    Serial.print("\nWiFi connected\nIP address: ");
    Serial.println(WiFi.localIP());
  }

  void setupCamera() {
    cameraCfg.ledc_channel = LEDC_CHANNEL_0;
    cameraCfg.ledc_timer = LEDC_TIMER_0;
    cameraCfg.pin_d0 = Y2_GPIO_NUM;
    cameraCfg.pin_d1 = Y3_GPIO_NUM;
    cameraCfg.pin_d2 = Y4_GPIO_NUM;
    cameraCfg.pin_d3 = Y5_GPIO_NUM;
    cameraCfg.pin_d4 = Y6_GPIO_NUM;
    cameraCfg.pin_d5 = Y7_GPIO_NUM;
    cameraCfg.pin_d6 = Y8_GPIO_NUM;
    cameraCfg.pin_d7 = Y9_GPIO_NUM;
    cameraCfg.pin_xclk = XCLK_GPIO_NUM;
    cameraCfg.pin_pclk = PCLK_GPIO_NUM;
    cameraCfg.pin_vsync = VSYNC_GPIO_NUM;
    cameraCfg.pin_href = HREF_GPIO_NUM;
    cameraCfg.pin_sscb_sda = SIOD_GPIO_NUM;
    cameraCfg.pin_sscb_scl = SIOC_GPIO_NUM;
    cameraCfg.pin_pwdn = PWDN_GPIO_NUM;
    cameraCfg.pin_reset = RESET_GPIO_NUM;
    cameraCfg.xclk_freq_hz = 20000000;
    cameraCfg.pixel_format = PIXFORMAT_JPEG;

    // Configurar dimensiones y calidad de imagen modestas para ahorra memoria, ancho de banda, espacio en el servidor y accelerar el proceso (suficiente para esta demostración)
    cameraCfg.frame_size = FRAMESIZE_SVGA;
    cameraCfg.jpeg_quality = 12;
    cameraCfg.fb_count = 1;

    // Arrancar cámara
    esp_err_t status = esp_camera_init(&cameraCfg);
    if (status != ESP_OK) {
      Serial.printf("Could not start camera: error code 0x%x", status);
      return;
    }
  }

  void connectMQTT() {
    randomSeed(micros());
    Serial.println("Checking MQTT connection...");
    while (!mqttClient.connected()) {
      Serial.println("Connecting to MQTT broker...");
      String clientId = MQTT_CLIENT_ID_PREFIX + String(random(0xffff), HEX);
      if (mqttClient.connect(clientId.c_str(), MQTT_USERNAME, MQTT_PWD)) {
        Serial.println("MQTT connected");
        mqttClient.subscribe(MQTT_SUB_TOPIC, QOS);
      } else {
        Serial.println("Error connecting to " + mqttClient.state());
        delay(5000);
      }
    }
  }

  void callback(char* topic, byte* payload, unsigned int length){
    Serial.println("callback() invoked");

    StaticJsonDocument<200> json;   // o DynamicJsonDocument json(200);

    DeserializationError jsonError = deserializeJson(json, payload);

    if (jsonError) {
      Serial.print(F("deserializeJson() failed: "));
      Serial.println(jsonError.f_str());
      return;
    }

    const char* sensorId = json[SENSOR_ID_KEY];
    const char* requestorId = json[REQUESTOR_ID_KEY];

    Serial.print("sensor: ");
    Serial.println(sensorId);

    Serial.print("requestor ID: ");
    Serial.println(requestorId);

    ntpClient.update();                       // Invocar funciones de sacar y subir la foto
    camera_fb_t * fb = takePhoto();
    uploadPhoto(fb, requestorId);
    esp_camera_fb_return(fb);

    Serial.println("Photo taken");
  }

  /* Sacar un foto y devolver los datos de imagen como objeto tipo Frame Buffer*/
  camera_fb_t * takePhoto() {
    camera_fb_t * fb = NULL;
    fb = esp_camera_fb_get();
    if(!fb) {
      Serial.println("Couldn't take photo!!!!");
    }
    return fb;
  }

  void uploadPhoto(camera_fb_t * fb, const char* requestorId){
    Serial.println("Uploading image by FTP...");
    Serial.println("Starting FTP session..");
    ftp.OpenConnection();
    Serial.println("FTP session started");
    ftp.ChangeWorkDir(FTP_PATH);
    ftp.InitFile("Type I");
    String filename = SENSOR_ID +  FILENAME_SANITISER + ntpClient.getFormattedDate() + FILE_EXTENSION;
    int l = filename.length() + 1;
    char dst[l];
    filename.toCharArray(dst, l);
    Serial.print("File name: ");
    Serial.println(dst) ;
    ftp.NewFile(dst);
    ftp.WriteData(fb->buf, fb->len);
    ftp.CloseFile();
    ftp.CloseConnection();

    if (!mqttClient.connected()) {
      Serial.println("MQTT not connected. Connecting now...");
      connectMQTT();         // Conectar al broker MQTT si no estamos conectados
    }
    else{
      Serial.println("MQTT already connected");
    }
    Serial.print("Publishing message: ");
    Serial.print(dst);
    Serial.print(" to topic: ");
    Serial.print(MQTT_PUB_TOPIC);
    Serial.println("...");

    char httpURL[strlen(HTTP_PATH) + strlen(dst)];     // objeto array para almacenar el URL HTTP completo a la imagen que acabamos de subir

    strcpy(httpURL, HTTP_PATH);                        // añadimos el dominio al URL
    strcat(httpURL, dst);                              // añadimos la ruta al URL

    StaticJsonDocument<200> json;
    json[SENSOR_ID_KEY] = SENSOR_ID;
    json[REQUESTOR_ID_KEY] = requestorId;
    json[PHOTO_URL_KEY] = httpURL;

    int payloadSize = measureJson(json) + 1;
    char payload[payloadSize];
    serializeJson(json, payload, sizeof(payload));

    mqttClient.publish(MQTT_PUB_TOPIC, payload, true);
    Serial.println("Message published");
  }

  void setup() {
    WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);           // deshabilitar detección de bajones de corriente

    Serial.begin(115200);

    setupWifi();
    setupCamera();
    ntpClient.begin();

    espClient.setCACert(ROOT_CA);                        // informar el certificado para la conexión MQTT
    mqttClient.setServer(MQTT_SERVER, MQTT_PORT);        // informar los parámetros del servidor
    mqttClient.setCallback(callback);                    // asignar la función "callback" (retrollamada) que se ejuctará cada vez que recibamos un mensaje MQTT
  }

  void loop() {
    if (!mqttClient.connected()) {
      connectMQTT();
    }
    mqttClient.loop();                                 // invocar la llamada "keepalive" del cliente MQTT
  }

Subir código al ESP32

Sigue estos pasos:

  1. Asegúrate que el pin GPIO0 haga cortocircuito como se ve en la tabla más arriba, conectándolo al pin GND del ESP32 o directamente al GND del adaptador USB/TTL (los adaptadores suelen tener dos pines GND).
  2. Resetea el ESP32 apretando el botón de reset de la AI Thinker con tu uña. No hace falta apretar fuerte; debes notar el botón bajar de forma sólida y subir cuando lo sueltas. También la luz de flash de la AI Thinker dispará.
  3. Herramientas > Placa, y seleccionar AI Thinker ESP32-CAM.
  4. Herramientas > Puerto, seleccionar el puerto USB que estás usando.
  5. Herramientas > Monitor Serie y especifica los baudio a 115200 (necesita corresponder al valor de esta linea en código ESP32: Serial.begin(115200)).
  6. Programa > Subir (o Ctrl + U).
  7. Desconecta GPIO0 de GND. IMPORTANTE - ¡¡¡Si no haces esto no podrás arrancar el ESP32!!!

Código de la App de Android

Crea un nuevo proyecto de Android Studio.

  1. Abre Android Studio.
  2. File > New > New Project.
  3. Selecciona Empty Activity.
    Nuevo proyecto de Android Studio
    Nuevo proyecto de Android Studio
  4. Dale a Next y:
    1. Da tu proyecto un nombre
    2. Da un nombre al package Java (o simplemente acceptar el por defecto)
    3. Especifica Java como lenguaje
    4. Dale a Finish
    Parámetros del proyecto nuevo de Android
    Parámetros del proyecto nuevo de Android
  5. Dos pestañas aparecerán en el IDE: activity_main.xml y MainActivity.java. activity_main.xml representa los componentes de nuestra aplicación y MainActivity.java contiene la lógica.

    Selecciona activity_main.xml y machaca encima de todo el XML que el IDE nos ha creado por defecto con lo siguiente:

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <ImageButton
            android:id="@+id/takeMusicRoomPhotoBtn"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="0dp"
            android:layout_marginTop="28dp"
            android:onClick="takePhoto"
            android:tag="musicRoom"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@android:drawable/ic_menu_camera" />
    
        <TextView
            android:id="@+id/status"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Status pending..."
            android:layout_marginStart="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.162" />
    
        <ImageView
            android:id="@+id/photoView"
            android:layout_width="377dp"
            android:layout_height="445dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.524"
            app:srcCompat="@android:drawable/ic_menu_camera" />
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    

    De esta forma hemos creado un botón para sacar la foto, una caja de texto para mostrar el estado de las llamadas MQTT y un componente Android tipo ImageView dónde nuestras fotos aparecerán.

    Ten en cuenta lo siguiente:

    1. Los valores android:id son importantes porque nuestro código Java necesitará hacer referencia a estos identificadores.
    2. El valor android:tag es "musicRoom" en este ejemplo. Lo puedes llamar lo que quieras, por supuesto, pero ten en cuenta que debe exactamente que el valor definido aquí const char* SENSOR_ID = "musicRoom"; en el código de la ESP32 Cam. Si es diferente, the ESP32 no hará caso al mensaje MQTT. Esto es para que multiples cámaras puedan suscribir a nuestro tópico takePhoto y para que podamos añadir más botones a nuestra app de Android.
    3. La propiedad android:onClick="takePhoto" muestra un error (una linea roja ondulada por debajo del valor). Esto porque ningún método takePhoto() existe todavía. Es que nos falta picar el código de Java ;)

Configurar el manifest de Android

Añade las propiedades resaltadas a continuación a manifests/AndroidManifest.xml:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="io.friedchips.android.mqttphotodemo">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.READ_PHONE_STATE" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MQTTPhotoDemo"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            android:configChanges="orientation|screenSize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service android:name="org.eclipse.paho.android.service.MqttService"></service>
    </application>

</manifest>

Dependencias de Gradle

Añade las dependencias resaltadas a continuación a app/gradle.build:
  plugins {
      id 'com.android.application'
  }

  android {
      compileSdk 32

      defaultConfig {
          applicationId "io.friedchips.android.mqttphotodemo"
          minSdk 21
          targetSdk 32
          versionCode 1
          versionName "1.0"

          testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
      }

      buildTypes {
          release {
              minifyEnabled false
              proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
          }
      }
      compileOptions {
          sourceCompatibility JavaVersion.VERSION_1_8
          targetCompatibility JavaVersion.VERSION_1_8
      }
  }

  dependencies {
      implementation 'androidx.appcompat:appcompat:1.4.2'
      implementation 'com.google.android.material:material:1.6.1'
      implementation 'androidx.constraintlayout:constraintlayout:2.1.4'

      implementation 'com.squareup.picasso:picasso:2.71828'
      implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.2'
      implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.0.2'

      testImplementation 'junit:junit:4.13.2'
      androidTestImplementation 'androidx.test.ext:junit:1.1.3'
      androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
  }

Ahora importa estas dependencias al proyecto de Android dando a Sync Project with Gradle Files en la barra de menús de Android Studio.

Importar dependencies de Gradle a Android Studio
Importar dependencies de Gradle a Android Studio

En la pestaña activity_main.xml haz clic en la vista Design (1) para mostrar una representación visual de la maquetación.

Vista maqueta de Android
Vista maqueta de Android

Puedes hacer clic en la vista de Code (2) para volver al XML.

Añadir el código Java

Copia y pega el siguiente código en MainActivity.java por debajo de tus declaraciones de package:

package io.friedchips.android.mqttphotodemo;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;

import com.squareup.picasso.Picasso;

import org.eclipse.paho.android.service.MqttAndroidClient;
import org.eclipse.paho.client.mqttv3.IMqttActionListener;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.IMqttToken;
import org.eclipse.paho.client.mqttv3.MqttCallback;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.json.JSONObject;

import java.io.UnsupportedEncodingException;

public class MainActivity extends AppCompatActivity {
    protected static final String TAG = "MQTTPhotoDemo";  // ID for logging purposes
    protected static final String MQTT_SERVER_URL = "ssl://xxxxxxxxxxxxxxxxxxxs1.eu.hivemq.cloud:8883";
    protected static final String MQTT_USER = "your user";
    protected static final String MQTT_PWD = "your password";
    protected static final String MQTT_TAKE_PHOTO_TOPIC = "yourTopics/takePhoto";
    protected static final String MQTT_PHOTO_TAKEN_TOPIC = "ourTopics/photoTaken";
    protected static int QOS = 2;

    protected static final String REQUESTOR_ID_KEY = "requestorId";
    protected static final String CLIENT_ID = "myAndroidPhone";  // will be included in incoming messages; indicates whether this device requested a photo
    protected static final String SENSOR_ID_KEY = "sensorId";
    protected static final String PHOTO_URL_KEY = "photo";

    MqttAndroidClient mqttClient;
    MqttConnectOptions mqttConnectionOptions;

    ImageView photoView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        photoView = (ImageView) findViewById(R.id.photoView);
        connectMQTT();
    }

    public void connectMQTT(){
        mqttConnectionOptions = new MqttConnectOptions();
        mqttConnectionOptions.setUserName(MQTT_USER);
        mqttConnectionOptions.setPassword(MQTT_PWD.toCharArray());

        String clientId = MqttClient.generateClientId();
        mqttClient = new MqttAndroidClient(this.getApplicationContext(), MQTT_SERVER_URL, clientId);

        try {
            IMqttToken token = mqttClient.connect(mqttConnectionOptions);
            token.setActionCallback(new IMqttActionListener() {
                @Override
                public void onSuccess(IMqttToken asyncActionToken) {
                    Log.d(TAG, "Connected to " + MQTT_SERVER_URL);
                    try {
                        subscribe(MQTT_PHOTO_TAKEN_TOPIC);
                    } catch (Exception e) {
                        Log.e(TAG, "subscribe() failed");
                        e.printStackTrace();
                    }
                }

                @Override
                public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
                    Log.d(TAG, "Couldn't connect to " + MQTT_SERVER_URL);
                    Log.e(TAG, "reason: ", exception);
                }
            });
        } catch (MqttException e) {
            e.printStackTrace();
        }
    }

    public void subscribe(String topic) throws MqttException {
        if(mqttClient.isConnected()){
            Log.d(TAG, "subscribing to " + topic + "...");
            mqttClient.subscribe(MQTT_PHOTO_TAKEN_TOPIC, QOS);
            mqttClient.setCallback(new MqttCallback() {
                   @Override
                   public void connectionLost(Throwable cause) {
                       Log.e(TAG, "Connection lost during callback()", cause);
                       Log.i(TAG, "reconnecting...");
                       updateStatus("reconnecting...");
                       connectMQTT();
                   }

                   @Override
                   public void messageArrived(String topic, MqttMessage msg) throws Exception {
                       Log.d(TAG, "Received message from " + topic);
                       JSONObject json = new JSONObject(new String(msg.getPayload()));
                       final String sensorId = json.getString(SENSOR_ID_KEY);
                       final String requestorId = json.getString(REQUESTOR_ID_KEY);
                       final String photoURL = json.getString(PHOTO_URL_KEY);
                       Log.d(TAG, "sensorId = " + sensorId);
                       Log.d(TAG, "requestorId = " + requestorId);
                       Log.d(TAG, "photoURL = " + photoURL);

                       if(!CLIENT_ID.equals(requestorId) && !"".equals(requestorId)){
                           Log.d(TAG, "Photo requested by some other device.");
                           return;
                       }
                       // else..
                       Log.d(TAG, new String(msg.getPayload()));
                       Picasso.get()
                               .load(photoURL)
                               .into(photoView);

                       updateStatus("Photo sent (sensor ID: " + sensorId + ")");
                   }

                   @Override
                   public void deliveryComplete(IMqttDeliveryToken token) {;}
               }
            );
        }
        else{
            Log.d(TAG, "NOT connected!!!!!!");
        }
    }

    public void publish(final String topic, final String payload){

        if(!mqttClient.isConnected()){
            Log.w(TAG, "not connected during publish(). reconnecting...");
            updateStatus("reconnecting...");
            connectMQTT();
        }

        byte[] bytes = new byte[0];
        try {
            bytes = payload.getBytes("UTF-8");
            MqttMessage message = new MqttMessage(bytes);
            message.setQos(QOS);
            mqttClient.publish(topic, message);
        } catch (UnsupportedEncodingException | MqttException e) {
            e.printStackTrace();
        }
        Log.d(TAG, "published to  " + topic);
    }

    public void takePhoto(View view){
        Log.d(TAG, "takePhoto()  ");
        String sensorId = (String) view.getTag();   // This value comes from the Button component in activity_main.xml
        Log.d(TAG, "sensor ID  " + sensorId);
        updateStatus("Taking photo (sensor ID: " + sensorId + ")...");
        String json = "{\"" + SENSOR_ID_KEY + "\":\"" + sensorId + "\", \"" + REQUESTOR_ID_KEY + "\":\"" + CLIENT_ID + "\"}";
        publish(MQTT_TAKE_PHOTO_TOPIC, json);
    }

    public void updateStatus(final String s){
        ((TextView)findViewById(R.id.status)).setText(s);
    }
}
Explicación del código

En pocas palabras: el código Android monta la conexión MQTT, suscribe al tópico photoTaken (que es dónde el ESP32 publicará), invoca la librería Picasso para descargar la foto por HTTP y mostrarla en nuestro componente PhotoView.

Parámetros constantes
Constante Valor Observaciones
MQTT_SERVER_URL El URL de tu servidor MQTT
MQTT_USER Tu usuario MQTT
MQTT_PWD Tu contraseña MQTT
MQTT_TAKE_PHOTO_TOPIC yourtopics/takePhoto Debe ser idéntico al valor en tu código ESP32 exactamente
MQTT_PHOTO_TAKEN_TOPIC yourtopics/photoTaken Debe ser idéntico al valor en tu código ESP32 exactamente
CLIENT_ID Un identificador para este móvil Debe ser único si vas a usar varios móviles. Si varios móviles comparten un identificador, cada un recibirá la fotos solicitadas por los otros.
Detalles de la lógica de Android

Miramos lo que hace el código de Android:

  1. La propiedad android:onClick="takePhoto" del botón en activity_main.xml invoca el método takePhoto().
  2. El método takePhoto() primero genera la payload de JSON del mensaje MQTT, que incluye:

    • El identificador del ESP32 Cam con quién queremos hablar. Esto viene de la propiedad android:tag del botón en el fichero activity_main.xml.
    • El identificador del móvil, que el ESP32 publicará al tópico MQTT después sacar la foto.
    • Tu identificar cliente MQTT.

    Una vez generado el JSON, el método llama al método publish().

  3. El método publish() publica el mensaje JSON al tópico MQTT takePhoto.

    Cuando el ESP32 Cam recibe el mensaje sacará una foto, la subirá por FTP y nos notificará del URL HTTP.

Probar en el Emulador de Android Studio

Se trata de ejecutar nuestra app en un dispositivo virtual en Android Studio.

Instalar un Emulador de Android Studio

Si no tienes un dispositivo virtual instalado, sigue estos pasos. Si ya tienes uno, puedes saltar al paso de pruebas:

  1. En el desplegable Device en la parte superior derecha de la pantalla selecciona Device Manager. La ventana de gestor de dispositivos se abrirá con la opción Virtual visible port defecto.
  2. Haz clic en Create Device.
  3. Selecciona un modelo de móvil (cualquier sirve para esta demostración) en el diálogo Select Hardware y haz clic en Next.
  4. Selecciona una imagen de sistema en el siguiente diálogo (idoneamente con target Android 11 o superior), espera que se cargue y haz clic en Finish.

El móvil virtual que hayas elegido aparecerá en la ventana de Device Manager y estará disponible en el desplable de Available Devices.

Lanzar el Emulador de Anroid Studio

Selecciona tu móvil virtual en el desplegable Available Devices (1) y haz clic en el icono Run (2).

Iniciar Emulador de Android Studio
Iniciar Emulador de Android Studio

El móvil virtual aparecerá en la ventana de Emulator.

Con el ESP32 Cam encendido (y apuntando a algo interesante), haz clic en el botón take photo (1) en el emulador.

Una foto aparecerá el el componente de ImageView(2) :)

Prueba en el emulador de Android Studio
Prueba en el emulador de Android Studio

Instalación de la App al Móvil

Primero, asegúrate que el ESP32 Cam esté conectado por el cable USB a tu PC y Depuración por USB esté habilitada Si no has hecho ningún desarollo con tu móvil antes, ves a Ajustes > Sistema y toca Número de Compilación siete veces. Esto habilitará el menú Opciones para desarrolladores. Luego ves a Ajustes > Opciones para desarrolladores y habilita Depuración por USB.

Ahora tu móvil estará disponible en el desplegable Available Devices (y en el Device Manager en la pestaña Physical).

Con tu móvil seleccionado en el desplegable Available Devices, haz clic en el icono Run. Esto instalará tu app (junto con un icon) en tu móvil y lanzarla.

Instalación de app de Android a móvil
Instalación de app de Android a móvil

Ahora puedes desconectar tu móvil del PC y simplemente tocar el icono para arrancar la app cuando quieras.

¡Saca una foto IoT desde tu móvil con MQTT!

Abre la app en tu móvil tocando el nuevo icono y toca el botón take photo. Aparecerá una foto enviada desde el ESP32 Cam.

App IoT MQTT en móvil Android
App IoT MQTT en móvil Android

Recuerda que el ESP32 Cam ya no tiene que estar conectado a tu PC. Solo necesita tener corriente (una pila de 5v, por ejemplo) y dentro del alcance del wifi que has configurado. De eso se trata IoT.

ESP32 Cam standalone (1)
ESP32 Cam standalone (1)
ESP32 Cam standalone (2)
ESP32 Cam standalone (2)
ESP32 Cam standalone (3)
ESP32 Cam standalone (3)

¡¡¡A disfutar!!!

© 2022