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


Cerrar