Community project

Asistente Portátil Multifunción

ESP32
Photo of Asistente Portátil Multifunción

Alejandro Ramírez

Last updated November 1, 2026

This portable multifunctional assistant runs on an ESP32 with integrated display and battery monitoring, connecting to WiFi for cloud-based services. The project demonstrates how to build a compact, battery-powered device that combines local processing with remote capabilities, complete with real-time battery status display and efficient power management.

This guide provides everything needed to assemble the PaperDIY platform, connect the hardware components, and deploy the firmware. Makers will receive a complete wiring diagram, parts list, step-by-step assembly instructions, and the source code for WiFi connectivity, JSON data handling, and battery monitoring through the M5PM1 power management module.

Wiring diagram

Wiring diagram for Asistente Portátil Multifunción
Loading diagram…

Assemble it in 2 steps

1. Monta el PaperDIY

Con el USB desconectado, monta la pantalla y la placa siguiendo las instrucciones del kit. No necesitas componentes ni cables adicionales.

  • Manipula la cinta de la pantalla con cuidado y sin forzar el conector. Evita cortocircuitar o perforar la batería.

2. Conecta el dispositivo

Conecta el PaperDIY al ordenador con un cable USB-C de datos. Coloca el dispositivo al alcance de la red Wi-Fi de 2,4 GHz.

Deploy the firmware

#include <Arduino.h>
#include <M5Unified.h>
#include <M5PM1.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <esp_heap_caps.h>
#include <esp_system.h>
#include "wifi_config.h"

#ifndef CONFIG_SPIRAM_MODE_OCT
#error Este proyecto necesita la PSRAM octal del PaperDIY
#endif
#include "download_helpers.inc"

M5Canvas canvas(&M5.Display);
#include "photo_quality.inc"
// PaperDIY: official M5PM1 readVbat API. No periodic screen refresh.
M5PM1 batteryMonitor;
bool batteryMonitorReady = false;
void drawBattery() {
  if (!batteryMonitorReady) return;
  uint16_t mv = 0;
  if (batteryMonitor.readVbat(&mv) != M5PM1_OK || mv < 2500 || mv > 4500) return;
  // Voltage-based estimate, not a coulomb-counting fuel gauge.
  const int volts[] = {3300,3500,3600,3700,3750,3800,3850,3900,4000,4100,4200};
  const int levels[] = {0,5,10,20,30,40,50,60,75,90,100};
  int percent = 100;
  if (mv <= volts[0]) percent = 0;
  else for (int i=1; i<11; ++i) {
    if (mv <= volts[i]) {
      percent = levels[i-1] + (mv-volts[i-1])*(levels[i]-levels[i-1])/(volts[i]-volts[i-1]);
      break;
    }
  }
  // Compact status indicator in the upper-right, within the 32 px header.
  const int x=canvas.width()-46, y=8;
  canvas.setTextColor(TFT_BLACK, TFT_WHITE);
  canvas.setFont(&fonts::Font2);
  canvas.setTextSize(1);
  canvas.setTextDatum(middle_right);
  canvas.drawString(String(percent)+"%", x-6, y+9);
  canvas.drawRect(x,y,30,18,TFT_BLACK);
  canvas.fillRect(x+30,y+6,3,6,TFT_BLACK);
  int fill=26*percent/100;
  if (fill) canvas.fillRect(x+2,y+2,fill,14,TFT_BLACK);
}
constexpr uint32_t CONNECT_TIMEOUT = 10000;
constexpr uint32_t RECONNECT_INTERVAL = 15000;
constexpr uint32_t REFRESH_INTERVAL = 30UL * 60 * 1000;
constexpr uint32_t ERROR_RETRY_INTERVAL = 5UL * 60 * 1000;

// Result is passed by value through a FreeRTOS queue. The image has one owner.
struct Result { ImageBuffer *image; char error[100]; char name[80]; char breed[80]; char age[64]; char zone[192]; };
QueueHandle_t resultQueue;
bool ready = false, connecting = false, connectedBefore = false;
bool usingBackupWifi = false;
bool workerBusy = false, requestPending = false, hasDog = false;
uint32_t connectionStarted = 0, disconnectedAt = 0, lastFetchAt = 0;
uint32_t fetchInterval = REFRESH_INTERVAL;
String displayedMessage;
String currentName;
String currentAge, currentZone;
M5Canvas loadingDots(&M5.Display);
uint32_t lastLoadingFrame = 0;
uint8_t loadingFrame = 0;

// The selected fonts are ASCII-only. Convert UTF-8 before measuring, wrapping
// or drawing text so accented names remain legible on the display.
String displayText(const char *input) {
  String out;
  const uint8_t *p = reinterpret_cast<const uint8_t *>(input);
  while (*p) {
    uint32_t cp = *p++;
    if (cp >= 0x80) {
      int extra = (cp >= 0xC2 && cp <= 0xDF) ? 1 :
                  (cp >= 0xE0 && cp <= 0xEF) ? 2 :
                  (cp >= 0xF0 && cp <= 0xF4) ? 3 : 0;
      if (!extra) { out += '?'; continue; }
      cp &= (1U << (6 - extra)) - 1;
      bool valid = true;
      for (int i = 0; i < extra; ++i) {
        if ((*p & 0xC0) != 0x80) { valid = false; break; }
        cp = (cp << 6) | (*p++ & 0x3F);
      }
      if (!valid) { out += '?'; continue; }
    }
    if (cp >= 0x20 && cp <= 0x7E) out += (char)cp;
    else if (cp >= 0x0300 && cp <= 0x036F) continue; // combining accents
    else if (cp >= 0xC0 && cp <= 0xC5) out += 'A';
    else if (cp == 0xC7) out += 'C';
    else if (cp >= 0xC8 && cp <= 0xCB) out += 'E';
    else if (cp >= 0xCC && cp <= 0xCF) out += 'I';
    else if (cp == 0xD1) out += 'N';
    else if ((cp >= 0xD2 && cp <= 0xD6) || cp == 0xD8) out += 'O';
    else if (cp >= 0xD9 && cp <= 0xDC) out += 'U';
    else if (cp == 0xDD || cp == 0x178) out += 'Y';
    else if (cp >= 0xE0 && cp <= 0xE5) out += 'a';
    else if (cp == 0xE7) out += 'c';
    else if (cp >= 0xE8 && cp <= 0xEB) out += 'e';
    else if (cp >= 0xEC && cp <= 0xEF) out += 'i';
    else if (cp == 0xF1) out += 'n';
    else if ((cp >= 0xF2 && cp <= 0xF6) || cp == 0xF8) out += 'o';
    else if (cp >= 0xF9 && cp <= 0xFC) out += 'u';
    else if (cp == 0xFD || cp == 0xFF) out += 'y';
    else if (cp == 0xA0) out += ' ';
    else if (cp == 0x2018 || cp == 0x2019) out += '\'';
    else if (cp == 0x2013 || cp == 0x2014) out += '-';
    else out += '?';
  }
  return out;
}

void clearCanvas() {
  canvas.fillSprite(TFT_WHITE);
}

// UTF-8-safe shortening, measured using the same Unicode font as the strip.
String fitDetail(String text, int maxWidth) {
  if (canvas.textWidth(text) <= maxWidth) return text;
  while (text.length() && canvas.textWidth(text + "...") > maxWidth) {
    int i = text.length() - 1;
    while (i > 0 && ((uint8_t)text[i] & 0xC0) == 0x80) --i;
    text.remove(i);
  }
  return text + "...";
}
void detailCell(String text, bool location, int left, int right, int y) {
  // Render the tilde ourselves: the bundled efont omits this Spanish glyph.
  // Keep ASCII glyph metrics identical for centering and shortening.
  String original = text;
  text.replace("ñ", "n");
  text.replace("Ñ", "N");
  canvas.setFont(&fonts::efontCN_24);
  if (canvas.textWidth(text) > right-left-44) canvas.setFont(&fonts::efontCN_16);
  text = fitDetail(text, right-left-44);
  const int iconW=18, gap=10;
  int x=left+(right-left-iconW-gap-canvas.textWidth(text))/2;
  if (location) {
    canvas.drawCircle(x+9,y-4,7,TFT_BLACK);
    canvas.drawCircle(x+9,y-4,2,TFT_BLACK);
    canvas.drawLine(x+3,y+1,x+9,y+10,TFT_BLACK);
    canvas.drawLine(x+15,y+1,x+9,y+10,TFT_BLACK);
  } else {
    canvas.drawRect(x,y-9,18,18,TFT_BLACK);
    canvas.drawFastHLine(x,y-3,18,TFT_BLACK);
    canvas.drawFastVLine(x+4,y-12,6,TFT_BLACK);
    canvas.drawFastVLine(x+13,y-12,6,TFT_BLACK);
    canvas.fillRect(x+4,y+1,3,3,TFT_BLACK);
    canvas.fillRect(x+11,y+1,3,3,TFT_BLACK);
  }
  canvas.setTextDatum(middle_left);
  canvas.drawString(text,x+iconW+gap,y);
  int column = 0;
  for (unsigned int i = 0; i < original.length();) {
    uint8_t c = original[i];
    if (c == 0xC3 && i + 1 < original.length() &&
        ((uint8_t)original[i+1] == 0xB1 || (uint8_t)original[i+1] == 0x91)) {
      if (column < (int)text.length() && (text[column] == 'n' || text[column] == 'N')) {
        int gx = x + iconW + gap + canvas.textWidth(text.substring(0, column));
        int gw = canvas.textWidth(text.substring(column, column+1));
        int top = y - canvas.fontHeight()/2;
        // Small wave, above the letter and inside the row's reserved space.
        canvas.drawLine(gx+1, top+2, gx+gw/3, top, TFT_BLACK);
        canvas.drawLine(gx+gw/3, top, gx+2*gw/3, top+2, TFT_BLACK);
        canvas.drawLine(gx+2*gw/3, top+2, gx+gw-1, top, TFT_BLACK);
      }
      i += 2;
    } else {
      ++i;
      while (i < original.length() && ((uint8_t)original[i] & 0xC0) == 0x80) ++i;
    }
    ++column;
  }
}
// Keep the original name/breed styling; reserve one quiet row underneath.
void footer(const char *breed) {
  const int width = canvas.width();
  const int bottom = canvas.height();
  canvas.setTextColor(TFT_BLACK, TFT_WHITE);
  canvas.setTextDatum(middle_center);
  canvas.setFont(&fonts::FreeSansBold18pt7b);
  String name = currentName.length() ? displayText(currentName.c_str()) : "Un nuevo amigo";
  if (canvas.textWidth(name) > width - 32) canvas.setFont(&fonts::FreeSans12pt7b);
  while (name.length() > 1 && canvas.textWidth(name) > width - 32) name.remove(name.length()-1);
  canvas.drawString(name, width / 2, bottom - 118);
  canvas.setFont(&fonts::FreeSans12pt7b);
  String subtitle = displayText(breed);
  while (subtitle.length() > 1 && canvas.textWidth(subtitle) > width - 32) subtitle.remove(subtitle.length()-1);
  if (subtitle.length()) canvas.drawString(subtitle, width / 2, bottom - 78);
  const int divider = width * 2 / 5;
  const int rowY = bottom - 30;
  canvas.drawFastVLine(divider, rowY-15, 30, TFT_BLACK);
  detailCell(currentAge.length() ? currentAge : "Sin edad", false, 12, divider-10, rowY);
  detailCell(currentZone.length() ? currentZone : "Sin ubicacion", true, divider+10, width-12, rowY);
}
String currentBreed;
// E-paper is slow to refresh: one playful waiting screen, not a looping animation.
void showLoading() {
  if (!ready || displayedMessage == "__loading__") return;
  displayedMessage = "__loading__";
  hasDog = false;
  clearCanvas();
  const int x = canvas.width() / 2;
  const int y = canvas.height() / 2 - 48;
  // A paw print, drawn directly so it needs no extra image or storage.
  canvas.fillEllipse(x, y + 26, 55, 42, TFT_BLACK);
  canvas.fillEllipse(x - 58, y - 22, 20, 27, TFT_BLACK);
  canvas.fillEllipse(x - 20, y - 48, 20, 27, TFT_BLACK);
  canvas.fillEllipse(x + 20, y - 48, 20, 27, TFT_BLACK);
  canvas.fillEllipse(x + 58, y - 22, 20, 27, TFT_BLACK);
  canvas.setTextColor(TFT_BLACK, TFT_WHITE);
  canvas.setTextDatum(middle_center);
  canvas.setFont(&fonts::FreeSans12pt7b);
  canvas.drawString("Buscando un amigo...", x, y + 110);
  drawBattery();
  canvas.pushSprite(0, 0);
}
// Animate only a small area, at a cadence suitable for e-paper.
void animateLoading() {
  if (displayedMessage != "__loading__" ||
      (!workerBusy && !connecting) || millis()-lastLoadingFrame < 1800 ||
      !loadingDots.width()) return;
  lastLoadingFrame = millis();
  loadingDots.fillSprite(TFT_WHITE);
  loadingFrame = (loadingFrame + 1) % 3;
  for (int i=0; i<3; ++i)
    loadingDots.fillCircle(20+i*32, 12, i==loadingFrame ? 7 : 3, TFT_BLACK);
  loadingDots.pushSprite((canvas.width()-104)/2, canvas.height()/2+100);
}
void showInfo(const char *message) {
  if (!ready || displayedMessage == message) return;
  displayedMessage = message;
  hasDog = false;
  clearCanvas();
  canvas.setTextColor(TFT_BLACK, TFT_WHITE);
  canvas.setTextDatum(middle_center);
  canvas.setFont(&fonts::FreeSans12pt7b);
  // Wrap messages so network errors remain legible in portrait orientation.
  String lines[4];
  int count = 0;
  String text = displayText(message);
  while (text.length() && count < 4) {
    int end = text.length();
    while (end > 1 && canvas.textWidth(text.substring(0, end)) > canvas.width()-32) --end;
    if (end < (int)text.length()) {
      int space = text.lastIndexOf(' ', end);
      if (space > 0) end = space;
    }
    lines[count++] = text.substring(0, end);
    text.remove(0, end);
    text.trim();
  }
  int y = canvas.height()/2 - (count-1)*18;
  for (int i=0; i<count; ++i) canvas.drawString(lines[i], canvas.width()/2, y+i*36);
  drawBattery();
  canvas.pushSprite(0, 0);
}

// Perretex returns one image and its dog's details per request.
// All network I/O stays in the worker so buttons remain responsive.
bool loadCandidate(String &url, char *name, size_t nameSize,
                   char *breed, size_t breedSize,
                   char *age, size_t ageSize, char *zone, size_t zoneSize,
                   char *errorText, size_t errorSize) {
  ImageBuffer json(8192);
  if (!download("https://perretex.com/api/v1/images/random", json, errorText, errorSize, "API")) return false;
  JsonDocument doc;
  DeserializationError error = deserializeJson(doc, (const char *)json.data, json.length);
  const char *status = doc["status"] | "";
  const char *image = doc["message"] | "";
  if (error || strcmp(status, "success")) {
    strlcpy(errorText, "JSON incorrecto. Pulsa un boton.", errorSize);
    return false;
  }
  url = image;
  if (url.length() > 512 || !url.startsWith("https://perretex.com/api/images/") ||
      url.indexOf('\\') >= 0 || url.indexOf('"') >= 0 ||
      url.indexOf('\n') >= 0 || url.indexOf('\r') >= 0) {
    strlcpy(errorText, "JSON sin foto valida. Pulsa un boton.", errorSize);
    return false;
  }
  strlcpy(name, doc["dog"]["name"] | "", nameSize);
  strlcpy(breed, doc["dog"]["breed"] | "", breedSize);
  // Only use documented model fields: age + age_unit, and the complete zone.
  String ageText;
  JsonVariant value = doc["dog"]["age"];
  String unit = displayText(doc["dog"]["age_unit"] | "");
  unit.toLowerCase();
  if (!value.isNull() && (value.is<int>() || value.is<float>() || value.is<const char *>())) {
    String number = value.as<String>();
    if (unit == "ano" || unit == "anos" || unit == "year" || unit == "years")
      ageText = number + (number == "1" ? " año" : " años");
    else if (unit == "mes" || unit == "meses" || unit == "month" || unit == "months")
      ageText = number + (number == "1" ? " mes" : " meses");
  }
  strlcpy(age, ageText.c_str(), ageSize);
  strlcpy(zone, doc["dog"]["zone"] | "", zoneSize);
  return true;
}

void fetchTask(void *) {
  Result result = {nullptr, "Error de red. Pulsa un boton.", "", ""};
  Serial.println("Perretex: iniciando solicitud");
  String url;
  if (loadCandidate(url, result.name, sizeof(result.name), result.breed, sizeof(result.breed), result.age, sizeof(result.age), result.zone, sizeof(result.zone), result.error, sizeof(result.error))) {
    ImageBuffer *image = new ImageBuffer(4 * 1024 * 1024);
    if (!image) {
      strlcpy(result.error,"Falta memoria. Pulsa un boton.",sizeof(result.error));
    } else if (!download(url, *image, result.error, sizeof(result.error), "Foto")) {
      delete image;
    } else {
      int w=0,h=0; bool png=false,progressive=false;
      bool valid=dimensions(image->data,image->length,w,h,png,progressive);
      if (!valid) {
        if (image->length >= 12 && memcmp(image->data,"RIFF",4)==0 && memcmp(image->data+8,"WEBP",4)==0)
          Serial.println("Foto recibida en WebP; este visor no tiene decodificador WebP");
        else Serial.println("Formato de foto no reconocido");
        delete image;
        strlcpy(result.error,"No se pudo mostrar la foto. Pulsa un boton.",sizeof(result.error));
      } else {
        result.image=image;
        result.error[0]=0;
        Serial.printf("Foto recibida: %d x %d, %u bytes\n",w,h,(unsigned)image->length);
      }
    }
  }
  xQueueSend(resultQueue,&result,portMAX_DELAY);
  vTaskDelete(nullptr);
}
void startFetch() {
  if (!ready || workerBusy || WiFi.status()!=WL_CONNECTED) return;
  workerBusy=true; requestPending=false; lastFetchAt=millis();
  if (xTaskCreate(fetchTask,"dog-fetch",12288,nullptr,1,nullptr)!=pdPASS) {
    workerBusy=false; fetchInterval=ERROR_RETRY_INTERVAL;
    showInfo("Falta memoria. Pulsa un boton.");
  } else showLoading(); // Network task starts before the slow panel refresh.
}
void showResult(Result &result) {
  workerBusy=false; lastFetchAt=millis(); fetchInterval=ERROR_RETRY_INTERVAL;
  if (!result.image) { showInfo(result.error); return; }
  int w=0,h=0; bool png=false,progressive=false;
  bool valid=dimensions(result.image->data,result.image->length,w,h,png,progressive);
  if (!valid) {
    delete result.image;
    showInfo("No se pudo mostrar la foto. Pulsa un boton.");
    return;
  }
  // Do not enlarge the low-resolution preview of progressive JPEGs.
  if (progressive) {
    Serial.println("Foto JPEG progresiva: se necesita JPEG baseline");
    delete result.image;
    showInfo("No se pudo mostrar la foto. Pulsa un boton.");
    return;
  }
  clearCanvas();
  // Use almost the entire portrait screen, with just enough room for the
  // name and breed. Preserve every edge of landscape and portrait photos.
  const int left=4, top=32, areaW=canvas.width()-8;
  const int areaH=canvas.height()-188;
  // Fit the complete photo to the available area, without cropping.
  float scale=min((float)areaW/w,(float)areaH/h);
  int x=left+(areaW-(int)(w*scale))/2;
  int y=top+(areaH-(int)(h*scale))/2;
  bool ok=png ? canvas.drawPng(result.image->data,result.image->length,x,y,0,0,0,0,scale) :
      canvas.drawJpg(result.image->data,result.image->length,x,y,0,0,0,0,scale);
  delete result.image;
  if (!ok) {
    Serial.println("Error al dibujar la foto");
    displayedMessage="";
    showInfo("No se pudo mostrar la foto. Pulsa un boton.");
    return;
  }
  enhancePhoto(x, y, max(1, (int)(w*scale)), max(1, (int)(h*scale)));
  Serial.printf("Foto: original %dx%d, escala %.3f, pantalla %dx%d\n",
                w,h,scale,canvas.width(),canvas.height());
  currentName = result.name;
  currentBreed = result.breed;
  currentAge = result.age;
  currentZone = result.zone;
  footer(currentBreed.c_str());
  displayedMessage="";
  drawBattery();
  canvas.pushSprite(0,0);
  hasDog=true; fetchInterval=REFRESH_INTERVAL;
}
void beginConnection() {
  if (workerBusy) return;
  WiFi.disconnect();
  const char *ssid = usingBackupWifi ? WIFI_BACKUP_SSID : WIFI_SSID;
  const char *password = usingBackupWifi ? WIFI_BACKUP_PASSWORD : WIFI_PASSWORD;
  Serial.printf("Wi-Fi: conectando a %s\n", ssid);
  WiFi.begin(ssid,password);
  connectionStarted=millis(); connecting=true;
  showLoading();
}
void setup() {
  Serial.begin(115200);
  Serial.printf("Motivo de arranque: %d\n", (int)esp_reset_reason());
  auto cfg=M5.config(); cfg.clear_display=false; M5.begin(cfg);
  batteryMonitorReady = batteryMonitor.begin(&M5.In_I2C, M5PM1_DEFAULT_ADDR, M5PM1_I2C_FREQ_100K) == M5PM1_OK;
  M5.Display.setRotation(1);
  // Choose the portrait orientation regardless of the panel's native rotation.
  if (M5.Display.width() > M5.Display.height()) M5.Display.setRotation(0);
  M5.Display.setEpdMode(epd_mode_t::epd_quality);
  canvas.setPsram(true);
  // Keep all 256 luminance values until the panel driver quantizes to 16.
  canvas.setColorDepth(lgfx::color_depth_t::grayscale_8bit);
  if (!canvas.createSprite(M5.Display.width(),M5.Display.height())) {
    M5.Display.println("Error de memoria"); return;
  }
  loadingDots.setColorDepth(lgfx::color_depth_t::grayscale_8bit);
  loadingDots.createSprite(104,24);
  ready=true;
  resultQueue=xQueueCreate(1,sizeof(Result));
  if (!resultQueue) { ready=false; M5.Display.println("Error de memoria"); return; }
  WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(false); WiFi.setSleep(false);
  beginConnection();
}
void loop() {
  M5.update();
  if (!ready) { delay(50); return; }
  bool connected=WiFi.status()==WL_CONNECTED;
  Result result;
  if (xQueueReceive(resultQueue,&result,0)==pdTRUE) showResult(result);
  if (connected && !connectedBefore) {
    connecting=false; requestPending=true;
    Serial.printf("Wi-Fi conectado: %s, RSSI %d dBm, %lu ms\n", WiFi.SSID().c_str(), WiFi.RSSI(), (unsigned long)(millis()-connectionStarted));
  }
  else if (!connected && connectedBefore) {
    connecting=false; disconnectedAt=millis();
    usingBackupWifi=false;
    showInfo("Sin Wi-Fi. Pulsa un boton.");
  }
  connectedBefore=connected;
  if (M5.BtnA.wasPressed() || M5.BtnB.wasPressed()) {
    if (connected && !workerBusy) { requestPending=true; }
    else if (!workerBusy && !connecting) beginConnection();
  }
  if (!connected && connecting && millis()-connectionStarted>=CONNECT_TIMEOUT) {
    WiFi.disconnect(); connecting=false; disconnectedAt=millis();
    if (!usingBackupWifi) {
      usingBackupWifi=true;
      beginConnection();
    } else {
      usingBackupWifi=false;
      showInfo("Sin Wi-Fi. Pulsa un boton.");
    }
  }
  if (!connected && !connecting && !workerBusy && millis()-disconnectedAt>=RECONNECT_INTERVAL) beginConnection();
  if (connected && !workerBusy && (requestPending || millis()-lastFetchAt>=fetchInterval)) startFetch();
  animateLoading();
  delay(20);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik