Servidor OTA para Esp32 en red local

Presentación
Con el avance de los trabajos de programacion en micropython sobre placas esp32 surgio la necesidad de poder actualizar los archivos grabados en la placa sin acceder a traves del puerto USB lo que me llego a investigar sobre los procesos OTA (Over-The-Air).
Si bien lo mas difundido para el trabajo con actualizaccion OTA es usar Github como repositorio me parecio interesante la posibilidad de instalar un servidor OTA en un servidor LAMP (Linux, Apache, MySQL y PHP) instalado en una computadora conectada a una red wifi local.
Este enfoque permite obtener mayor velocidad en la actualizacion y un mayor control sobre todo el procedimiento como se verá en el desarrollo del proyecto.
Cabe destacar que si bien este proyecto esta diseñado para funcionar en un servidor local puede con minimas mejoras en lo que hace a la colocación de claves de acceso instalar en un servidor externo lo que permitiria el acceso a traves de internet
Servidor
El servidor consta de dos archivos index.php y control_actualizacion.php y una base de datos base_ota
El archivo index.php implementa un panel de administración web para el sistema OTA (Over-The-Air), esta diseñado para gestionar la subida, registro y seguimiento de archivos del destinados a dispositivos remotos Esp32. Permite a los administradores subir nuevas versiones de archivos, organizados por modelo de dispositivo, y monitorear el estado de actualizacion los equipos que se han conectado al servidor
El primer proceso que realiza el usuario es seleccionar o introducir un modelo de dispositivo (con autocompletado desde base de datos + opción de crear nuevo) es importante tener en cuenta que este modelo debera coincidir exactamente con el valor que luego se le dará a la variable MODELO en el archivo ota.py en la placa esp32, luego indicara la versión del archivo y finalmente deberá seleccionar el archivo que va a subir al servidor y mediante el botón Subir archivo se subira el archivo que sera colocado dentro de la carpeta archivos en una subcarpeta que tendra el mismo nombre que el modelo del dispositivo
Es necesario tener en cuenta que el nombre del archivo es único para cada modelo (no pueden existir dos archivos con el mismo nombre en el mismo modelo); si se subiera un segundo archivo con el mismo nombre, este sobreescribira al primero, esta sobre escritura no solo se produce al subir el archivo al servidor sino tambien al actualizar los archivos alojados en la placa esp32 como se vera mas adelante al realizarse la actualizacion el sistema lo que hace es sobreescribir el archivo viejo con el contenido del nuevo, y de no existir escribira un nuevo archivo
Como ya se adelanto al Subir archivo el sistema verifica si existe la carpeta con el nombre del modelo de dispositivo, de no ser asi la crea y luego guarda el archivo esa carpeta específica para ese modelo, calcula el hash SHA-256 del archivo (para verificación de integridad) e inserta o actualiza el registro en la base de datos archivos. Si ya existe un archivo con el mismo nombre y modelo lo actualiza (versión, hash, fecha) y si no existe crea un nuevo registro. Estos datos se visualizan en el sector archivos disponibles del panel del servidor
Finalmente en el ultimo sector del panel Último contacto de cada equipo se muestra cada placa que se ha contactado con el servidor indicando fecha y hora, del manejo de esta tabla hablaremos a continuacion cuando analicemos el archivo control_actualizacion.php
El archivo control_actualizacion.php es el Punto de acceso para las placas ESP32 que verifican si hay archivos actualizados disponibles de forma remota (OTA — Over The Air) y de ser asi requieren su envio. Responde en formato JSON y se integra con la base de datos MySQL que gestiona versiones de los archivos, además de registrar cada consulta en un log de actividad que se ve reflejado en el sector Último contacto de cada equipo del panel del servidor
Al ser requerido por una placa Esp32 prepara una consulta SQL segura seleccionando todos los archivos asociados a un modelo (equipo) que realizo la petición cuya fecha_subida sea posterior a una fecha recibida ordenando los resultados por fecha ascendente (del más antiguo al más reciente) con el resultado de la consulta para cada archivo encontrado genera una URL de descarga que es enviada a la placa mediante un JSON que ademas contiene el SHA256 y el nombre del archivo. Luego registra el contacto en la tabla log_equipos de la base de datos insertando o actualizando una entrada en la tabla log_equipos con modelo (equipo), Fecha/hora actual (en UTC) y el estado.
La base de datos que guarda toda la informacion se llama base_ota y se compone de dos tablas; la tabla archivos que almacena metadatos de los archivos disponibles para actualización OTA y contiene los campos: id, modelo, version, archivo, sha256 y fecha_subida y la tabla log_equipos que registra el historial de interacciones de los dispositivos con el sistema OTAy contiene los campos equipo_id, fecha_subida y estado
Los archivos detallados se localizan en el servidor teniendo la siguiente estructura:

Cabe destacar que las carpetas Esp32_cam_1 y Esp32_S3_1 son creadas automaticamente al subirse el primer archivo correspondiente a esa placa/modelo
Archivos en las placas Esp32
En las placas Esp32 se debe colocar un codigo especifico en el archivo boot.py y ademas debera estar el archivo ota.py que contiene la programacion de la actualización OTA, como veremos a continuación el archivo boot.py deberá garantizar que antes de ejecutarse la actualización OTA se haya establecido la coneccion con la red
El archivo boot.py es la inicialización del sistema en la Esp32. Se ejecuta automáticamente al encender o reiniciar el dispositivo (antes que main.py) y realiza las siguientes tareas: conectar el dispositivo a una red WiFi y verificar y aplicar actualizaciones OTA (Over-The-Air). Puede adicionarse otras tareas en funcion de las necesidades del uso de la placa pero es importante que se mantenga siempre primero la conexión wifi e inmediatamente a continuación la actualización OTA.
El archivo ota.py es el programa principal de este sistema cuyas funciones son verificar, descargar, validar e instalar actualizaciones de archivos desde el servidor remoto, manteniendo sincronizada la versión del software del dispositivo y es llamado desde boot,py luego de establecida la conexion con la red wifi.
En este archivo encontramos las siguientes partes
1. Configuración inicial
En la configuracion inicial se establece el valor de dos constantes esenciales para el funcionamiento de todo el sistema de actualizacion: la constante URL_SERVIDOR cuyo valor "http://{ip del servidor}/ota/control_actualizacion.php" debe ser exactamente el link del archivo control_actualizacion.php en el servidor y la constante MODELO cuyo valor debe ser exactamente igual al que se use en el servidor para subir los archivos a actualizar. Resulta necesario destacar que todas las placas que tengan el mismo valor de MODELO se actualizaran con los mismos archivos.
2. Función subir_programa(url, archivo_nombre)
Realiza una petición HTTP GET para descargar los archivos actualizados desde el servidor y los guarda de manera temporaria para realizar las verificaciones necesarias antes de modificar el archivo anterior. La peticion se realiza con una url enviada por el servidor OTA al informar que hay archivos actualizables
3. Función verifica_sha256(archivo_nombre, sha256_esperado)
Esta funcion verifica la integridad del archivo descargado comparando su hash SHA256 con el enviado por el servidor, para ello lee el archivo en bloques de 512 bytes y calcula su hash que compara con el recibido.
4. Función principal controla_actualiza()
Se encarga de todo el proceso de verificación, descarga, instalación y registro de las actualizaciones, lo que realiza a traves de una serie de pasos conde llama a las distintas funciones ya descriptas.
🔍 Paso 1: Leer versión actual
Lee la fecha/hora de la última actualización desde actual.txt y si no existe, usa una fecha por defecto (01/01/2000).
📡 Paso 2: Consultar al servidor
Construye una URL con el DEVICE_ID y la versión actual con la cual hace una petición GET al servidor, recibiendo luego esta respuesta en formato JSON.
📦 Paso 3: Procesar respuesta del servidor
La respuesta se recibe en un JSON con el siguiente formato:

Y si no hubiera actualizaciones pendientes el formato del JSON es el siguiente:

🔄 Paso 4: Descarga de archivos
Para cada archivo en la lista de actualizacion descarga el archivo a un temporal (nombre.tmp), verifica su hash SHA256. y si es válido elimina la versión anterior (si existe) y renombra el temporal al nombre final.
⏱️ Paso 5: Actualizar actual.txt
Siempre actualiza actual.txt con la hora del servidor (incluso si no hubo actualizaciones), para mantener sincronizado el control de versiones.
✅ Paso 6: Reinicio de la placa
Si se actualizó al menos un archivo se reinicia la placa y si no hubo actualizaciones o falló la actualizacion se ejecuta el programa principal .
Finalmente agrego los archivos conexion.py y main.py a modo de ejemplo que he usado para probar el sistema.
Codigos
En primer lugar pondre los codigos de los archivos que estan en el servidor:
index.php
<?php
// 🔐 Define las credenciales
$host = "localhost";
$usuario = "xxxxxx";
$contraseña = "123456789";
$base_datos = "base_ota";
// Función para sanitizar nombre de carpeta (evitar inyecciones)
function sanitizeFolderName($name) {
return preg_replace('/[^a-zA-Z0-9_\-]/', '_', $name);
}
// Variable para controlar si se debe mostrar solo el mensaje
$mostrar_mensaje_final = false;
$mensaje_final = '';
$tipo_mensaje = ''; // 'exito' o 'error'
// ✅ Subida de archivo (solo si se envió el formulario)
if ($_POST && isset($_FILES['archivos'])) {
$modelo = trim($_POST['modelo']);
$version = trim($_POST['version']);
$archivo = $_FILES['archivos'];
// Sanitizar modelo para nombre de carpeta
$modelo_carpeta = sanitizeFolderName($modelo);
$carpeta_modelo = "archivos/" . $modelo_carpeta . "/";
// Crear carpeta si no existe
if (!is_dir($carpeta_modelo)) {
mkdir($carpeta_modelo, 0755, true);
}
$nombre_archivo = basename($archivo['name']);
$ruta_destino = $carpeta_modelo . $nombre_archivo;
if (move_uploaded_file($archivo['tmp_name'], $ruta_destino)) {
$sha256 = hash_file('sha256', $ruta_destino);
// Conectar a la base de datos
$conexion = new mysqli($host, $usuario, $contraseña, $base_datos);
if ($conexion->connect_error) {
$mensaje_final = "❌ Error de conexión a la base de datos.";
$tipo_mensaje = 'error';
} else {
// Verificar si ya existe un registro con mismo modelo y nombre de archivo
$chequeo_registro = $conexion->prepare("SELECT id FROM archivos WHERE modelo = ? AND archivo = ?");
$chequeo_registro->bind_param("ss", $modelo, $nombre_archivo);
$chequeo_registro->execute();
$resultado_chequeo = $chequeo_registro->get_result();
if ($resultado_chequeo->num_rows > 0) {
// ACTUALIZAR registro existente
$registro = $resultado_chequeo->fetch_assoc();
$actualiza_registro = $conexion->prepare("UPDATE archivos SET version = ?, sha256 = ?, fecha_subida = NOW() WHERE id = ?");
$actualiza_registro->bind_param("ssi", $version, $sha256, $registro['id']);
if ($actualiza_registro->execute()) {
$mensaje_final = "✅ archivo actualizado correctamente.";
$tipo_mensaje = 'exito';
} else {
$mensaje_final = "❌ Error al actualizar en la base de datos: " . $actualiza_registro->error;
$tipo_mensaje = 'error';
}
$actualiza_registro->close();
} else {
// INSERTAR nuevo registro
$nuevo_registro = $conexion->prepare("INSERT INTO archivos (modelo, version, archivo, sha256, fecha_subida) VALUES (?, ?, ?, ?, NOW())");
$nuevo_registro->bind_param("ssss", $modelo, $version, $nombre_archivo, $sha256);
if ($nuevo_registro->execute()) {
$mensaje_final = "✅ archivo subido y registrado correctamente.";
$tipo_mensaje = 'exito';
} else {
$mensaje_final = "❌ Error al registrar en la base de datos: " . $nuevo_registro->error;
$tipo_mensaje = 'error';
}
$nuevo_registro->close();
}
$chequeo_registro->close();
$conexion->close();
}
} else {
$mensaje_final = "❌ Error al mover el archivo al servidor.";
$tipo_mensaje = 'error';
}
// ✅ Activar modo "mensaje final"
$mostrar_mensaje_final = true;
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Panel OTA - Subir archivo</title>
<meta charset="UTF-8">
<style>
body { background: #f5d183ff; font-family: Arial, sans-serif; margin: 40px; }
table { border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #cea946ff; }
.form-group { margin-bottom: 15px; }
label { display: inline-block; width: 120px; }
button { background-color: #d88759ff; }
input { background-color: #ebd245ff; }
select { background-color: #f3dfa6ff;
}
</style>
</head>
<body>
<?php if ($mostrar_mensaje_final): ?>
<script>
// Mostrar alerta con el mensaje
alert("<?php echo addslashes($mensaje_final); ?>");
// Redirigir después de aceptar
window.location.href = "index.php";
</script>
<?php
exit; // Detener ejecución para no mostrar el resto de la página
?>
<?php endif; ?>
<hr>
<center>
<h1>Servidor OTA</h1>
<button onclick="window.location.href='/'">Menú Inicio</button>
</center>
<hr>
<h2>📤 Subir Nuevo archivo</h2>
<?php
// Obtener modelos existentes para el desplegable
$consulta_modelos = new mysqli($host, $usuario, $contraseña, $base_datos);
$modelos = [];
if (!$consulta_modelos->connect_error) {
$resultado_modelos = $consulta_modelos->query("SELECT DISTINCT modelo FROM archivos ORDER BY modelo");
while ($registro = $resultado_modelos->fetch_assoc()) {
$modelos[] = $registro['modelo'];
}
$consulta_modelos->close();
}
?>
<form method="post" enctype="multipart/form-data">
<div class="form-group">
<label for="modelo_select">Modelo:</label>
<select id="modelo_select" name="modelo" onchange="entrada_modelos()" required>
<option value="">-- Seleccionar o escribir nuevo --</option>
<?php foreach ($modelos as $m): ?>
<option value="<?= htmlspecialchars($m) ?>"><?= htmlspecialchars($m) ?></option>
<?php endforeach; ?>
<option value="nuevo">➕ Nuevo modelo...</option>
</select>
<input type="text" id="modelo_texto" name="modelo_texto" placeholder="Escribe nuevo modelo" style="display:none; margin-left:10px;" />
</div>
<div class="form-group">
<label for="version">Versión:</label>
<input type="text" id="version" name="version" placeholder="Ej: 1" required />
</div>
<div class="form-group">
<label for="archivo">Archivo:</label>
<input type="file" name="archivos" required>
</div>
<button type="submit">💾 Subir archivo</button>
</form>
<script>
function entrada_modelos() {
const select = document.getElementById('modelo_select');
const input = document.getElementById('modelo_texto');
if (select.value === 'nuevo') {
input.style.display = 'inline-block';
input.required = true;
select.name = ''; // desactivar name del select
input.name = 'modelo'; // activar name del input
} else {
input.style.display = 'none';
input.required = false;
select.name = 'modelo';
input.name = '';
}
}
</script>
<hr>
<h3>📂 archivos disponibles</h3>
<?php
$conexion = new mysqli($host, $usuario, $contraseña, $base_datos);
if ($conexion->connect_error) {
echo "<p style='color:red;'>❌ Error de conexión a la base de datos.</p>";
} else {
$archivo = $conexion->query("SELECT id, modelo, version, archivo, fecha_subida FROM archivos ORDER BY fecha_subida DESC");
if ($archivo->num_rows > 0) {
echo "<table border='1' cellpadding='8' cellspacing='0'>";
echo "<tr><th>ID</th><th>Modelo</th><th>Versión</th><th>Archivo</th><th>Fecha de subida</th></tr>";
while ($registro = $archivo->fetch_assoc()) {
echo "<tr>";
echo "<td>" . $registro['id'] . "</td>";
echo "<td>" . htmlspecialchars($registro['modelo']) . "</td>";
echo "<td>" . htmlspecialchars($registro['version']) . "</td>";
echo "<td>" . htmlspecialchars($registro['archivo']) . "</td>";
echo "<td>" . $registro['fecha_subida'] . "</td>";
echo "</tr>";
}
echo "</table>";
} else {
echo "<p>No hay archivos subidos aún.</p>";
}
$conexion->close();
}
?>
<hr>
<h3>📋 Último contacto de cada equipo</h3>
<?php
$conexion = new mysqli($host, $usuario, $contraseña, $base_datos);
if ($conexion->connect_error) {
echo "<p style='color:red;'>❌ Error de conexión a la base de datos.</p>";
} else {
$contacto = $conexion->query("SELECT equipo_id, fecha_subida, estado FROM log_equipos ORDER BY fecha_subida DESC");
if ($contacto->num_rows > 0) {
echo "<table border='1' cellpadding='8' cellspacing='0'>";
echo "<tr><th>Equipo (Modelo)</th><th>Último Contacto (UTC)</th><th>Estado</th></tr>";
while ($registro = $contacto->fetch_assoc()) {
$estado_color = $registro['estado'] == 'Actualizo' ? 'green' : 'blue';
echo "<tr>";
echo "<td>" . htmlspecialchars($registro['equipo_id']) . "</td>";
echo "<td>" . $registro['fecha_subida'] . "</td>";
echo "<td style='color:{$estado_color}; font-weight:bold;'>" . htmlspecialchars($registro['estado']) . "</td>";
echo "</tr>";
}
echo "</table>";
} else {
echo "<p>No hay registros de contacto aún.</p>";
}
$conexion->close();
}
?>
</body>
</html>
control_actualizacion.php
<?php
// ✅ Desactiva la visualización de errores y advertencias
error_reporting(0);
ini_set('display_errors', 0);
// ✅ Asegura que la salida sea solo JSON
header('Content-Type: application/json; charset=utf-8');
// ✅ Asegura Hora UTC
date_default_timezone_set('UTC');
// ✅ Obtiene parámetros de la URL
$equipo_id = $_GET['id'] ?? '';
$fecha_actual_str = $_GET['version'] ?? '';
// ✅ Valida ID del equipo
if (empty($equipo_id)) {
echo json_encode([
"error" => "ID de equipo requerido",
"hora" => date('d/m/Y H:i:s')
]);
exit;
}
// ✅ Convierte la fecha recibida
$fecha_mysql = null;
if (!empty($fecha_actual_str)) {
$partes = explode(' ', $fecha_actual_str);
if (count($partes) >= 2) {
list($fecha, $hora) = $partes;
$fecha_partes = explode('/', $fecha);
if (count($fecha_partes) === 3) {
// DD/MM/YYYY → YYYY-MM-DD
$fecha_mysql = $fecha_partes[2] . '-' . $fecha_partes[1] . '-' . $fecha_partes[0] . ' ' . $hora;
}
}
}
if (!$fecha_mysql) {
// Si no se puede convertir
$fecha_mysql = '2000-01-01 00:00:00';
}
// ✅ Configuración de la base de datos
$host = "localhost";
$usuario = "xxxxxxx";
$contraseña = "123456789";
$base_datos = "base_ota";
// ✅ Conexión a la base de datos
$conexion = new mysqli($host, $usuario, $contraseña, $base_datos);
if ($conexion->connect_error) {
echo json_encode([
"error" => "Error de conexión a la base de datos",
"hora" => date('d/m/Y H:i:s')
]);
exit;
}
// ✅ Prepara y ejecuta la consulta: todos los archivos posteriores a $fecha_mysql
$consulta = "SELECT fecha_subida, archivo, sha256 FROM archivos WHERE modelo = ? AND fecha_subida > ? ORDER BY fecha_subida ASC";
$resultado = $conexion->prepare($consulta);
if (!$resultado) {
echo json_encode([
"error" => "Error al preparar la consulta",
"hora" => date('d/m/Y H:i:s')
]);
$conexion->close();
exit;
}
$resultado->bind_param("ss", $equipo_id, $fecha_mysql);
$resultado->execute();
$result = $resultado->get_result();
$archivos = [];
while ($row = $result->fetch_assoc()) {
// Convertir fecha_subida de MySQL (YYYY-MM-DD HH:MM:SS) a DD/MM/YYYY HH:MM:SS
$fecha_subida_mysql = $row['fecha_subida'];
$dt = new DateTime($fecha_subida_mysql);
$fecha_subida_formateada = $dt->format('d/m/Y H:i:s');
$archivos[] = [
"version_date" => $fecha_subida_formateada,
"url" => "http://{ip servidor}}/ota/archivos/" . urlencode($equipo_id) . "/" . urlencode($row['archivo']),
"sha256" => $row['sha256'],
"nombre" => $row['archivo']
];
}
// ✅ Fecha actual del servidor para que el ESP32 actualice actual.txt
$hora = date('d/m/Y H:i:s');
// ✅ Registrar o actualizar último contacto en log_equipos
$estado = !empty($archivos) ? 'Actualizo' : 'Verifico';
$resultado_log = $conexion->prepare("
INSERT INTO log_equipos (equipo_id, fecha_subida, estado)
VALUES (?, UTC_TIMESTAMP(), ?)
ON DUPLICATE KEY UPDATE
fecha_subida = UTC_TIMESTAMP(),
estado = ?
");
$resultado_log->bind_param("sss", $equipo_id, $estado, $estado);
$resultado_log->execute();
$resultado_log->close();
$response = [
"update_available" => !empty($archivos),
"archivos" => $archivos,
"hora" => $hora
];
echo json_encode($response, JSON_UNESCAPED_SLASHES);
// ✅ Limpieza
$resultado->close();
$conexion->close();
A continuacion los archivos que se colacan en la placa Esp32:
boot.py
# boot.py
import gc
import time
import machine
from conexion import Conexion
import ota
print("🔌 Iniciando sistema...")
# --- 1. Conexión WiFi ---
try:
print("📶 Conectando a red WiFi...")
conec = Conexion()
conec.conectar()
conec.esperar()
if not conec.wlan.isconnected():
raise Exception("❌ No se pudo establecer conexión WiFi.")
print("✅ WiFi conectado correctamente.")
gc.collect()
except Exception as e:
print(f"💥 Error crítico al conectar WiFi: {e}")
raise SystemExit("🔌 Deteniendo sistema. Conéctate por USB/REPL para debug.")
# --- 2. Verificación OTA ---
try:
print("📡 Verificando actualizaciones OTA...")
if ota.controla_actualiza():
print("🔁 Reiniciando para aplicar actualizaciones...")
time.sleep(2)
machine.reset() # Solo reinicia si hubo cambios
else:
print("✅ Sin actualizaciones pendientes.")
except Exception as e:
print(f"⚠️ Error durante OTA (continuando): {e}")
print("➡️ Inicialización completada. Ejecutando main.py automáticamente...")
gc.collect()
ota.py
import os
import gc
import urequests
import hashlib
import time
# --- CONFIGURACIÓN OTA ---
URL_SERVIDOR = "http://{ip servidor}}/ota/control_actualizacion.php"
MODELO = "modelo a usar"
def subir_programa(url, archivo_nombre):
try:
print(f"⬇️ Descargando desde: {url} → {archivo_nombre}")
respuesta = urequests.get(url)
if respuesta.status_code != 200:
print(f"❌ Error HTTP: {respuesta.status_code}")
respuesta.close()
return False
with open(archivo_nombre, "wb") as f:
f.write(respuesta.content)
respuesta.close()
gc.collect()
print(f"✅ Descargado correctamente: {archivo_nombre}")
return True
except Exception as e:
print(f"❌ Error al descargar {archivo_nombre}: {e}")
gc.collect()
return False
def verifica_sha256(archivo_nombre, sha256_esperado):
try:
with open(archivo_nombre, "rb") as archivo:
calculador_hash = hashlib.sha256()
bloque = archivo.read(512)
while bloque:
calculador_hash.update(bloque)
bloque = archivo.read(512)
resumen = calculador_hash.digest()
hash_archivo = ''.join('{:02x}'.format(b) for b in resumen)
if hash_archivo == sha256_esperado:
print("✅ SHA256 verificado correctamente.")
return True
else:
print("❌ SHA256 no coincide.")
return False
except FileNotFoundError:
print(f"❌ Archivo '{archivo_nombre}' no encontrado.")
return False
except Exception as e:
print(f"❌ Error al verificar SHA256: {e}")
return False
def controla_actualiza():
"""
Verifica si hay actualizaciones disponibles.
Retorna:
True → si se descargó e instaló al menos un archivo (requiere reinicio manual).
False → si no hay actualizaciones o hubo error (continúa normal).
"""
try:
# Leer la fecha actual desde actual.txt
try:
with open('actual.txt', 'r') as f:
current_date_str = f.read().strip()
except Exception:
print("⚠️ No se pudo leer actual.txt, usando fecha por defecto.")
current_date_str = "01/01/2000 00:00:00"
# Construir URL con la fecha
fecha = current_date_str.replace(' ', '%20')
url = f"{URL_SERVIDOR}?id={MODELO}&version={fecha}"
print(f"📡 Consultando actualización...")
print(url)
respuesta = urequests.get(url)
rta_cruda = respuesta.text.strip()
print("📡 Respuesta cruda del servidor:", repr(rta_cruda))
if not (rta_cruda.startswith('{') and rta_cruda.endswith('}')):
print("❌ La respuesta no parece JSON válido. Contenido recibido:")
print(rta_cruda)
respuesta.close()
gc.collect()
return False
data = respuesta.json()
respuesta.close()
gc.collect()
# Procesar actualizaciones
if data.get("update_available") and data.get("archivos"):
archivos = data["archivos"]
print(f"🆕 Se encontraron {len(archivos)} archivos para actualizar.")
actualizado = False
for item in archivos:
archivo_nombre = item.get("nombre")
url = item.get("url")
sha256 = item.get("sha256")
if not archivo_nombre or not url or not sha256:
print("❌ Archivo inválido en la lista (faltan campos). Saltando...")
continue
archivo_tmp = archivo_nombre + ".tmp"
print(f"📦 Procesando: {archivo_nombre}")
# 1. Descargar a archivo temporal
if not subir_programa(url, archivo_tmp):
print(f"❌ Error al descargar {archivo_nombre}. Saltando este archivo.")
continue
# 2. Verificar SHA256 del archivo temporal
if not verifica_sha256(archivo_tmp, sha256):
print(f"❌ Verificación fallida para {archivo_nombre}. Eliminando temporal.")
try:
os.remove(archivo_tmp)
except:
pass
continue
# 3. ✅ SOBRESCRIBIR EL ARCHIVO FINAL
try:
if archivo_nombre in os.listdir():
os.remove(archivo_nombre)
print(f"🗑️ Versión anterior de '{archivo_nombre}' eliminada.")
os.rename(archivo_tmp, archivo_nombre)
print(f"✅ '{archivo_nombre}' actualizado correctamente.")
actualizado = True # Al menos un archivo se actualizó
except Exception as e:
print(f"❌ Error al instalar {archivo_nombre}: {e}")
try:
os.remove(archivo_tmp)
except:
pass
continue
# ✅ Actualizamos hora en actual.txt
hora_servidor = data.get("hora")
if hora_servidor:
try:
with open('actual.txt', 'w') as f:
f.write(hora_servidor.strip())
print(f"✅ actual.txt actualizado a: {hora_servidor}")
except Exception as e:
print(f"❌ Error al actualizar actual.txt: {e}")
else:
print("⚠️ hora_servidor no recibido. actual.txt no fue actualizado.")
if actualizado:
print("🔄 Actualización completada. Considera reiniciar el dispositivo.")
return True # Indica que hubo cambios → boot.py puede reiniciar si lo desea
else:
print("⚠️ Ningún archivo se actualizó correctamente.")
return False
else:
# ✅ No hay actualizaciones → pero igual sincronizamos hora_servidor
hora_servidor = data.get("hora")
if hora_servidor:
try:
with open('actual.txt', 'w') as f:
f.write(hora_servidor.strip())
print(f"✅ actual.txt sincronizado con hora del servidor: {hora_servidor}")
except Exception as e:
print(f"❌ Error al actualizar actual.txt: {e}")
else:
print("⚠️ Hora del servidor no recibida.")
print("✅ Sistema actualizado.")
return False
except Exception as e:
print("❌ Error en OTA:", e)
gc.collect()
return False
actual.txt
este archivo simplemente regista la fecha de la ultima actualizacion conviene colocarlo con una fecha incial
01/01/2000 00:00:00
conexion.py
Este archivo se pone a modo de ejemplo, puede usarse el archivo de conexion que considera mas adecuado, siempre debe llamarse antes de ota.py
#Configuracion de conexion WIFI:
from time import sleep
import network
class Conexion:
red = "XXXXXXX"
clave = "123456789"
def __init__(mi, red='', clave=''):
network.WLAN(network.AP_IF).active(False) # disable access point
mi.wlan = network.WLAN(network.STA_IF)
mi.wlan.active(True)
if red == '':
mi.red = Conexion.red
mi.clave = Conexion.clave
else:
mi.red = red
mi.clave = clave
def conectar(mi, red='', clave=''):
if red != '':
mi.red = red
mi.clave = clave
if not mi.wlan.isconnected():
mi.wlan.connect(mi.red, mi.clave)
def status(mi):
if mi.wlan.isconnected():
return mi.wlan.ifconfig()
else:
return ()
def esperar(mi):
cnt = 30
while cnt > 0:
print("Esperando ..." )
# con(mi.red, mi.clave) # Connect to an red
if mi.wlan.isconnected():
print('Se conecto a: %s' % mi.red)
print('IP: %s\nSUBNET: %s\nGATEWAY: %s\nDNS: %s' % mi.wlan.ifconfig()[0:4])
cnt = 0
else:
sleep(5)
cnt -= 5
return
def scan(mi):
return mi.wlan.scan()
main.py
En este archivo va el inicio de la programacion de la placa, yo soloco un modelo que use para probar el sistema de actualización
# main.py
import time
import uasyncio as sy
import usocket as soc
import gc
APP_VERSION = "1"
html = """HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<html>
<head>
<meta charset="UTF-8">
<title>ESP32 App</title>
<style>
body {{ font-family: Arial, sans-serif; background: #e3f2fd; text-align: center; padding: 50px; }}
.box {{ background: white; padding: 20px; border-radius: 10px; box-shadow: 0 0 10px rgba(0,0,0,0.1); display: inline-block; }}
h1 {{ color: #1976d2; }}
</style>
</head>
<body>
<center>
<div class="box">
<h1>🚀 ¡Hola desde app.py v """ + APP_VERSION + """</h1>
<p>Estado: ✅ Funcionando</p>
<p>Memoria libre: """ + str(gc.mem_free()) + """ bytes</p>
<p>Memoria ocupada: """ + str(gc.mem_alloc()) + """ bytes</p>
<p>Esto parece terminado</p>
</div>
</body>
</html>"""
def crear_servidor():
s = soc.socket(soc.AF_INET, soc.SOCK_STREAM)
s.setsockopt(soc.SOL_SOCKET, soc.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 80))
s.listen(3)
print("Servidor escuchando en puerto 80...")
return s
async def handle_request(cs, rq):
if len(rq) > 1 and rq[1] == '/':
cs.send(b'%s' % html)
else:
cs.send(pag_error)
borrar(cs)
async def server_loop(servidor):
while True:
try:
servidor.settimeout(0.05)
cs, ca = servidor.accept()
cs.settimeout(0.5)
r = cs.recv(1024)
ms = r.decode('utf-8')
if 'favicon.ico' not in ms:
rq = ms.split(' ')
try:
print(rq[0], rq[1], ca)
await handle_request(cs, rq)
except:
borrar(cs)
else:
borrar(cs)
except OSError as e:
await sy.sleep_ms(10)
except Exception as e:
print("Error en server_loop:", e)
await sy.sleep_ms(10)
def main():
print(f"✅ Bienvenido a app.py versión {APP_VERSION}")
servidor = crear_servidor()
loop = sy.get_event_loop()
loop.create_task(server_loop(servidor))
print("Servidor funcionando!")
loop.run_forever()
if __name__ == "__main__":
main()
Base de datos
Para el funcionamineto de este proyecto es tambien necesaria una base de datos la cual es creada mediante la siguiente instrccuion SQL:
-- ¡ADVERTENCIA! Si exites eun base de datos: `base_ota` esto la BORRA con todos sus datos.
DROP DATABASE IF EXISTS `base_ota`;
-- Crea la base de datos nueva
CREATE DATABASE `base_ota`;
-- Usa la base de datos
USE `base_ota`;
-- Crea la tabla `archivos`
CREATE TABLE `archivos` (
`id` int NOT NULL AUTO_INCREMENT,
`modelo` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`version` varchar(20) NOT NULL,
`archivo` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
`sha256` char(64) NOT NULL,
`fecha_subida` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Crea la tabla `log_equipos`
CREATE TABLE `log_equipos` (
`equipo_id` varchar(100) NOT NULL,
`fecha_subida` datetime NOT NULL,
`estado` enum('Actualizo','Verifico') NOT NULL,
PRIMARY KEY (`equipo_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
Consideraciones finales
En este proyecto todavia queda pendiente la programacion de algunas cuestiones importantes que seran agregadas en futuras actualizaciones como son:
Eliminación de archivos obsoletos en la placa Esp32, esl sistema permite actualizar cualquir archivo de la placa y agregar nuevos pero no esta prevista la eliminacion de archvos que dejen de usarse
Identificacion individual de cada placa que se actualiza, el sistema hoy permite conocer la ultima actualizacion que se hizo de un modelo, pero de haber varias placas con el mismo modelo no queda registrada cual es la placa actualizada