← Índice del curso

Internet de las Cosas · Tema 38

Comandos, estados y confirmaciones

El dispositivo encendió la salida, pero se perdió la respuesta. El servicio reintenta y el equipo vuelve a iniciar los diez segundos de encendido. El mensaje parecía el mismo; el efecto ya no lo fue. Vamos a definir identidades, resultados y reglas de reintento para que una repetición no cambie la intención original ni convierta una duda en una falsa confirmación.

01 · Completar el protocolo de control

Intención, resultado y estado actual

En el tema 37 definimos una orden con permisos, vigencia y duración local. También observamos que un timeout no demuestra que la acción falló. Ahora resolvemos qué significa repetir esa orden y cómo comunicar lo ocurrido sin confundir tres cosas diferentes:

Tres preguntas relacionadas, con datos y tiempos propios.
ConceptoPreguntaEjemplo
Intención o estado solicitado¿Qué se pidió?ON durante diez segundos, orden A
Resultado del comando¿Qué se sabe de esa ejecución?A fue aplicada en t = 1 s
Estado reportado¿Qué observó el equipo y cuándo?Salida OFF, revisión 2, después de vencer el plazo

La intención no es una medición del mundo. Un resultado de ejecución tampoco es un reporte perpetuo: puede seguir diciendo que A encendió correctamente aunque la salida ya haya terminado su plazo. El estado reportado necesita calidad, origen y antigüedad, como vimos en el panel.

Para una acción puntual, la intención se expresa como comando. Para una configuración persistente puede existir un estado deseado con revisión y reconciliación. Comparten conceptos, pero no se transforma un encendido temporal en una autorización permanente simplemente guardando on=true en la nube.

Los ejemplos usan una salida temporizada y modelos locales. No envían órdenes a equipos reales. El tema 39 llevará estas distinciones al gemelo digital: cómo representar y reconciliar lo deseado y lo reportado por muchos dispositivos.

02 · Confirmar una etapa precisa

El ciclo de vida de un comando

Una respuesta útil dice a qué comando pertenece y qué etapa confirma. «OK» puede significar que una biblioteca aceptó bytes, que el broker recibió un mensaje, que el firmware admitió una solicitud o que una realimentación observó su efecto. El contrato debe elegir nombres inequívocos.

Estados de aplicación posibles; cada producto define las transiciones admitidas.
EstadoQué afirmaQué no afirma
PendienteEl servicio registró la intenciónQue el equipo ya la recibió
Aceptada / preparadaSe admitió y registró el intentoQue el efecto quedó aplicado y verificado
AplicadaTerminó la etapa de aplicación definida por el contratoQue la salida siga en ese estado ahora
RechazadaUna validación impidió la admisión de ese intentoQue una entrega anterior desconocida nunca haya actuado
FallidaSe detectó una falla en una etapa de ejecuciónQue no haya ocurrido ningún efecto parcial
InciertaFalta evidencia para establecer el resultado históricoQue convenga ejecutarla de nuevo automáticamente

El timeout es una condición de quien espera: «no recibí un resultado final dentro de mi plazo». No cambia por sí solo el estado del dispositivo ni convierte una operación en fallida. Una respuesta tardía puede aportar información y debe correlacionarse con la misma identidad.

Un resultado terminal no vuelve a «aceptada»

Una confirmación intermedia puede llegar después de la final. El servicio conserva el estado más avanzado dentro de las transiciones válidas. Si ya sabe que A fue aplicada, un acuse antiguo de aceptación no la devuelve a ejecución pendiente.

Los resultados finales contradictorios no se resuelven eligiendo el último mensaje recibido: indican una inconsistencia que debe diagnosticarse. «Aplicada» y «fallida» no son números que siempre se puedan ordenar sin conocer la ejecución. Un estado incierto puede necesitar una operación explícita de reconciliación.

El transporte no confirma la salida

QoS de MQTT se refiere al intercambio de mensajes, no a una transacción entre almacenamiento y actuador. La especificación MQTT 3.1.1 de OASIS define esas garantías de transporte. La confirmación de aplicación conserva identidad, resultado y evidencia del firmware.

03 · Qué significa «la misma orden»

Una identidad y un contenido estable

La identidad debe permanecer igual desde el primer intento hasta su resolución. En una plataforma suele estar dentro del alcance cliente o sitio + dispositivo + identificador de comando. Un nombre como A sirve para una simulación aislada, pero no para generar identidades únicas de toda una flota.

{
  "v": 1,
  "device": "aux07",
  "id": "A",
  "op": "set_output",
  "args": {"on": true, "duration_ms": 10000},
  "expires_at_ms": 1767225620000
}

Un reintento conserva ID, destinatario, operación, argumentos y vencimiento. Si se cambia duration_ms o se extiende la fecha límite, ya no es el mismo contenido. El receptor rechaza usar una identidad existente para una intención distinta.

Identidad nueva significa intención nueva

Si el cliente genera B después de agotar la espera de A, el equipo puede admitir B como otra solicitud. Aunque ambos cuerpos pidan ON durante diez segundos, aplicar B puede reiniciar el plazo. Un nuevo ID no es una forma inocua de «probar otra vez».

Comparar contenido normalizado

Los espacios o el orden de las propiedades JSON no deberían convertir una repetición legítima en conflicto. Primero se valida y se obtiene una representación normalizada de los campos que definen la intención. Se guarda esa representación, o una huella junto con una estrategia apropiada de comprobación.

Los ejemplos conservan una representación textual determinista de versión, destinatario, operación, duración y vencimiento. No usan una huella criptográfica ni la presentan como autenticación. Los permisos se comprueban antes de consultar o devolver registros; conocer un ID no autoriza a leer resultados ajenos.

El contrato también fija qué campos son solo del transporte, por ejemplo la fecha de este envío, y cuáles forman parte de la intención. La fecha límite debe permanecer incluida para que un reintento no rejuvenezca una orden vieja.

04 · El registro de comandos

Reintentar sin volver a aplicar

Un receptor protegido conserva un registro por identidad. Si la misma solicitud vuelve, consulta ese registro y devuelve su etapa o resultado, sin llamar otra vez al actuador ni reiniciar el temporizador.

autenticar y validar esquema
buscar identidad dentro de su dispositivo y ámbito

si existe:
    si el contenido difiere → conflicto, no ejecutar
    si terminó → devolver el resultado original
    si está preparada → informar su etapa, no lanzar otra ejecución
    si quedó incierta → conservar incertidumbre y reconciliar
si no existe:
    validar vigencia y condiciones locales
    registrar preparación
    ejecutar según el protocolo del actuador
    conservar el resultado y responder

La consulta y reserva de una identidad deben estar protegidas frente a concurrencia. Dos callbacks que buscan antes de insertar pueden encontrar «no existe» y ejecutar ambos. Se usa una restricción única y una transacción cuando corresponde, o una única tarea dueña del registro y la salida en el firmware.

Idempotencia no significa que nada cambió

La primera aplicación puede cambiar el sistema. Lo que se busca es que repetir la misma intención no agregue otro efecto lógico. El concepto de método idempotente en HTTP, RFC 9110, distingue ese efecto previsto de detalles como registrar varios intentos. Usar POST para crear comandos requiere su propio mecanismo de identidad; un nombre de método no resuelve el protocolo de la aplicación.

«Fijar ON» puede ser idempotente respecto de un booleano y dejar de serlo respecto del plazo si cada repetición concede diez segundos nuevos. «Alternar» o «dispensar una unidad» requieren todavía más cuidado: repetirlos puede cambiar la dirección o repetir una acción irreversible.

Devolver el resultado que ocurrió

Si A se aplicó en t = 1 s con vencimiento local a los 11 s, el reintento en t = 5 devuelve ese hecho. No crea una nueva hora de aplicación ni cambia el límite a 15. Si el reintento llega después de los 11 s, puede devolver el resultado histórico aunque la salida ya esté OFF.

El orden de consultas importa: una identidad conocida con el mismo contenido puede recuperar su resultado después de vencer la admisión original. Eso no vuelve a admitir ni a ejecutar la orden. Una identidad desconocida y vencida no se ejecuta; si hubo pérdida de historial, tampoco prueba que jamás haya actuado.

05 · Correlación y revisiones

Confirmaciones que no hacen retroceder

Una respuesta de aplicación puede conservar estos conceptos, con nombres definidos por su versión:

{
  "v": 1,
  "device": "aux07",
  "command_id": "A",
  "stage": "applied",
  "result_revision": 2,
  "applied_at_ms": 1767225601000,
  "execution_boot": 17,
  "state_revision_at_apply": 41,
  "evidence": "output_written"
}

command_id correlaciona la respuesta. result_revision ordena la evolución de esa solicitud. La revisión del estado al aplicar pertenece al dispositivo y es otra secuencia. evidence evita confundir una salida escrita con una realimentación física verificada.

La aplicación conserva la revisión y el estado admitidos. Una respuesta repetida no agrega otra ejecución; una intermedia anterior no hace retroceder un resultado. Si cambia el arranque del equipo, la comparación de revisiones necesita el contrato de arranque y recuperación: no se comparan sin más dos contadores que volvieron a cero.

Resultado viejo, estado nuevo

Supongamos A = ON aplicada en t = 1 y una orden OFF aplicada en t = 6. La confirmación repetida de A llega a los 7 s. Esa respuesta completa el conocimiento de A, pero no debe escribir ON sobre un reporte OFF de una revisión posterior.

Por eso la interfaz mantiene por separado una lista de solicitudes y el estado reportado. Actualizar el botón principal con el valor del último resultado recibido mezcla dos historias y reintroduce el problema de los datos tardíos del tema 36.

Estado deseado frente a reportado

Un estado deseado persistente expresa una configuración objetivo, con su revisión. El reportado describe qué pudo aplicar u observar el dispositivo. La diferencia puede deberse a una entrega pendiente, un rechazo local, una versión incompatible o una falla. El receptor necesita una razón, no solo dos booleanos distintos.

Este tema resuelve comandos puntuales y sus resultados. En el próximo organizamos esas representaciones en un gemelo digital, sin asumir que una intención se convierte automáticamente en estado físico.

06 · Cuánto tiempo se recuerda

Espera, vencimiento y retención

Se conservan las tres distinciones del tema anterior: espera del solicitante, vigencia de admisión y duración local. Agregamos otra: horizonte de conservación del registro de identidad y resultado.

Preguntas que debe contestar cada plazo.
PlazoPreguntaEjemplo del laboratorio
Espera¿Hasta cuándo espero una confirmación final?Seis segundos desde la creación de cada solicitud
Admisión¿Todavía puede comenzar una ejecución nueva?Antes de t = 4 o t = 20, según la opción
Duración¿Hasta cuándo puede mantenerse esta activación?Diez segundos desde su aplicación original
Retención de resultados¿Por cuánto tiempo reconozco una repetición?60 s o, para mostrar el problema, solo 3 s desde el resultado

Si se elimina el resultado de A a los 5 s y llega su reintento en ese instante, el receptor puede volver a tratarla como nueva. Que el primer resultado haya quedado guardado alguna vez no garantiza deduplicación después de purgarlo.

El horizonte se diseña con el máximo tiempo de reintento, el transporte, la política de reconexión y la cantidad esperada de comandos. A tasa constante de un comando por minuto y retención de un día, hay hasta 1440 identidades de ese flujo por dispositivo, antes de añadir índices, resultados y márgenes.

No borrar incertidumbre como si fuera historia resuelta

Los intentos preparados o inciertos necesitan una política de resolución. El modelo no los purga automáticamente con el plazo de resultados aplicados. Si se llena el almacenamiento reservado para ellos, se limita la admisión y se informa el problema; borrar evidencia para seguir aceptando puede permitir repetir efectos.

Reintentar con límites

El cliente puede consultar el resultado por identidad antes de reenviar, usar esperas crecientes y un límite de intentos o de tiempo. Un error permanente de esquema, permisos o conflicto no se arregla enviando sin pausa. La política tampoco convierte un ID nuevo en un reintento del anterior.

Una cancelación es una solicitud con su propia carrera respecto de la ejecución. Cerrar una pantalla, dejar de esperar o vencer la admisión no deshace una acción que ya ocurrió. Para una salida activa existe además la operación y la política local de apagado.

07 · Lo que no resuelve un identificador

La ventana de incertidumbre al reiniciar

Un registro persistente ayuda a sobrevivir reinicios, pero un archivo y un GPIO no comparten automáticamente una transacción. Hay una ventana entre preparar, producir el efecto y guardar el resultado.

Ventana entre registrar intención y guardar resultado físico Primero se guarda una preparación, luego se aplica una salida y finalmente se conserva el resultado. Un corte después de la preparación puede ocurrir antes o después del efecto. Al recuperar solo la preparación no se puede afirmar cuál de esos casos ocurrió. PrepararRegistro persistenteAplicarEfecto sobre la salidaCompletarResultado persistente Si solo recuperamos «preparada», el resultado puede ser incierto. No se promete atomicidad entre una base y un actuador externo.

Si el corte ocurre antes de guardar la preparación, quizá no hubo admisión. Si ocurre después de preparar pero antes de completar, el efecto pudo ocurrir o no. Repetir ciegamente puede duplicarlo; devolver éxito inventaría evidencia que falta.

Una política conservadora

El laboratorio recupera una preparación incompleta como incierta y no vuelve a ejecutar al recibir el mismo comando. En el modelo sabemos que hubo una aplicación, porque vemos toda la simulación, pero el registro recuperado no demuestra un resultado terminal. La respuesta no presenta una hora de aplicación confirmada.

Resolver esa incertidumbre puede requerir realimentación, consultar un controlador que conserve secuencias, verificar una revisión aplicada o intervención del operador. Si el actuador admite una consigna repetible y una identidad propia, puede ofrecer un protocolo más fuerte que una escritura de pin sin memoria.

Reinicio después de completar

Si el resultado terminal sí quedó persistido, el receptor puede devolverlo después de arrancar sin repetir la acción. El estado actual puede haber cambiado: nuestro modelo arranca en OFF y usa otro identificador de arranque. El resultado histórico de A sigue asociado al arranque en que se ejecutó.

La política de arranque de la salida pertenece al equipo. No se restaura automáticamente un ON temporal con el argumento de que el comando figura aplicado. La duración y el origen de tiempo anterior tampoco se reciclan sin un diseño explícito.

08 · Atomicidad dentro de una base

Un registro transaccional en SQLite

Este primer programa cambia un estado lógico dentro de SQLite y conserva su resultado en la misma transacción. Aquí sí se pueden confirmar ambas escrituras como una unidad porque pertenecen a la misma base. No maneja un GPIO ni demuestra ejecución física exactamente una vez.

La clave única es dispositivo + comando, y se compara contenido normalizado antes de devolver el resultado. SQLite documenta el control explícito de transacciones BEGIN, COMMIT y ROLLBACK; la interfaz utilizada es sqlite3 de Python.

Dos ejemplos independientes. La primera ejecución descarga Pyodide y, para este bloque, sqlite3. Ambos programas también funcionan con Python 3 local. La base de este ejemplo está en memoria y no sobrevive al cierre.

import json
import sqlite3

db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE estado_logico (
  device TEXT PRIMARY KEY, salida INTEGER NOT NULL,
  hasta INTEGER, revision INTEGER NOT NULL
);
INSERT INTO estado_logico VALUES ('aux07', 0, NULL, 0);
CREATE TABLE comandos (
  device TEXT NOT NULL, id TEXT NOT NULL,
  contenido TEXT NOT NULL, resultado TEXT NOT NULL,
  PRIMARY KEY(device, id)
);
""")


def aplicar(c, ahora):
    # Ejemplo aislado: identidad autorizada y campos validados por el servicio.
    contenido = json.dumps([1, c["device"], c["op"], c["duracion"], c["expires"]],
                           separators=(",", ":"))
    db.execute("BEGIN IMMEDIATE")
    try:
        previo = db.execute("SELECT contenido, resultado FROM comandos WHERE device=? AND id=?",
                            (c["device"], c["id"])).fetchone()
        if previo is not None:
            if previo[0] != contenido:
                raise ValueError("Mismo ID con contenido diferente")
            resultado = json.loads(previo[1])
        else:
            if ahora >= c["expires"]:
                resultado = dict(fase="vencida", momento=ahora)
            else:
                if c["op"] not in ("on", "off") or (c["op"] == "on" and not 1 <= c["duracion"] <= 10):
                    raise ValueError("Operación o duración inválida")
                on = c["op"] == "on"
                hasta = ahora + c["duracion"] if on else None
                cur = db.execute("""
                  UPDATE estado_logico SET salida=?, hasta=?, revision=revision+1
                  WHERE device=?
                """, (int(on), hasta, c["device"]))
                if cur.rowcount != 1:
                    raise ValueError("Dispositivo desconocido")
                revision = db.execute("SELECT revision FROM estado_logico WHERE device=?",
                                      (c["device"],)).fetchone()[0]
                resultado = dict(fase="aplicada", momento=ahora, hasta=hasta, revision=revision)
            db.execute("INSERT INTO comandos VALUES (?, ?, ?, ?)",
                       (c["device"], c["id"], contenido, json.dumps(resultado)))
        db.execute("COMMIT")
        return resultado
    except Exception:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise


a = dict(device="aux07", id="A", op="on", duracion=10, expires=20)
primero = aplicar(a, 1)
assert primero["hasta"] == 11 and primero["revision"] == 1
assert aplicar(a, 5) == primero
assert db.execute("SELECT salida,hasta,revision FROM estado_logico").fetchone() == (1, 11, 1)
conflicto = False
try:
    aplicar(dict(a, duracion=5), 5)
except ValueError:
    conflicto = True
assert conflicto
b = dict(device="aux07", id="OFF", op="off", duracion=0, expires=20)
assert aplicar(b, 6)["revision"] == 2
assert aplicar(a, 30) == primero and db.execute("SELECT salida,revision FROM estado_logico").fetchone() == (0, 2)
c = dict(a, id="C", expires=4)
assert aplicar(c, 13)["fase"] == "vencida"
assert db.execute("SELECT COUNT(*) FROM comandos").fetchone() == (3,)
print("A original:", primero)
print("Reentrega A después de OFF:", aplicar(a, 30))
print("Estado lógico actual:", db.execute("SELECT salida,hasta,revision FROM estado_logico").fetchone())
print("8 comprobaciones superadas: el resultado de A no vuelve a aplicar ON.")
db.close()

A conserva aplicación en t = 1, límite 11 y revisión 1. Su reentrega no modifica esos campos. Después de OFF, el estado lógico queda apagado en revisión 2; consultar otra vez A devuelve su resultado histórico. Las ocho comprobaciones terminan sin errores.

Esta tabla no tiene un temporizador ejecutándose: hasta es un dato lógico que una parte de ejecución tendría que interpretar. Tampoco se purgan identidades ni se simula un corte. Esas limitaciones son deliberadas para mostrar la diferencia entre atomicidad en la base y un efecto externo.

Si se escribiera el GPIO entre el UPDATE y el COMMIT, un rollback de SQLite no desharía esa escritura física. Para el equipo hacen falta el registro recuperable, las etapas de ejecución y la política de incertidumbre descritas antes.

09 · Tiempos y supuestos de la simulación

Modelo del intercambio

El segundo ejemplo representa un equipo con salida temporizada y una copia del conocimiento del servicio. Se recorre de t = 0 a t = 24 s:

  1. El servicio crea A en t = 0; A llega al equipo a los 1 s y solicita ON durante diez segundos.
  2. El equipo registra una preparación, aplica la salida y completa el resultado al segundo siguiente.
  3. Un resultado tarda otros dos segundos en volver. La primera confirmación terminal puede perderse.
  4. Se fuerza una segunda entrega en t = 5 o 13, para estudiar tanto reintentos como duplicados del transporte.
  5. Un acuse intermedio de A puede llegar tarde, a los 10 s, después de una confirmación terminal.
  6. Opcionalmente llega OFF a los 6 s, con identidad distinta, o el equipo se reinicia a los 2 o 4 s.

La salida siempre arranca apagada después del reinicio. La revisión del estado se numera dentro de cada arranque; un resultado histórico conserva el arranque y revisión de su intento. El contador «Aplicaciones de ON» cuenta llamadas de aplicación, no necesariamente flancos físicos: volver a fijar ON puede extender el plazo sin apagar antes. La orden OFF es una intención nueva creada en t = 6 y tiene su propio vencimiento a partir de ese instante.

El registro persistente es una abstracción: el modelo conserva sus entradas al reiniciar, sin simular sectores, desgaste o escrituras parciales. Los resultados aplicados pueden vencer por retención; las entradas inciertas no se purgan automáticamente. Los datos de identidad y tiempos ya se consideran validados.

Un resultado no actualiza la salida del panel

En esta simulación se muestran por separado el estado interno del dispositivo y las solicitudes del servicio. Los mensajes de resultado no escriben sobre ese estado. En un sistema real, el panel obtiene el estado por un reporte y conserva su antigüedad; no tiene acceso directo al estado físico que aquí conoce el simulador.

La comparación sin registro es intencionalmente insuficiente: permite que una reentrega vuelva a ejecutar. También se fuerza una variante de ID repetido con cuerpo distinto, aunque el servicio de producción debería rechazar esa mutación antes de reenviarla. El dispositivo mantiene su comprobación como parte del contrato de extremo a extremo.

10 · Seguir cada mensaje

Simulación ejecutable en Python

Este programa reproduce el laboratorio y compara el caso protegido con fallas de retención, pérdida de registro y cambios de identidad. Las estructuras en memoria representan tanto el almacenamiento como los mensajes pendientes; no realizan conexiones ni escrituras de GPIO.

import json


def simular(registro=True, persistente=True, retencion=60, reintento=5,
            identidad="igual", perderPrimera=True, reinicio="ninguno",
            apagar=False, acuseTardio=True, ttl=20):
    def crear(id, op="on", duracion=10):
        return dict(id=id, device="aux07", v=1, op=op, duracion=duracion, expires=ttl)

    def huella(c):
        return json.dumps([c["v"], c["device"], c["op"], c["duracion"], c["expires"]],
                          separators=(",", ":"))

    primera = crear("A")
    segunda = crear("B" if identidad == "nueva" else "A", "on", 5 if identidad == "cambiada" else 10)
    cache = {}
    servicio = {"A": dict(id="A", creada=0, fase="pendiente", rango=0, origen=None, motivo="")}
    trabajos, mensajes, salida, hasta, boot, revision = [], [], False, None, 1, 0
    aplicaciones = repetidas = conflictos = 0
    filas = []
    for t in range(25):
        acciones = []
        if t == 0:
            acciones.append("Servicio crea la orden A: ON durante 10 s")
        if (reinicio == "antes" and t == 2) or (reinicio == "despues" and t == 4):
            boot += 1
            revision, salida, hasta, trabajos = 0, False, None, []
            if not persistente:
                cache.clear()
            else:
                for r in cache.values():
                    if r["fase"] == "preparada":
                        r.update(fase="incierta", terminada=t, motivo="Reinicio sin resultado terminal")
            acciones.append("Reinicio: salida OFF; se pierde el trabajo en curso")
        for id, r in list(cache.items()):
            if r["fase"] == "aplicada" and t >= r["terminada"] + retencion:
                del cache[id]
                acciones.append(f"Vence el registro de {id}")
        if salida and hasta is not None and t >= hasta:
            salida, hasta, revision = False, None, revision + 1
            acciones.append("Apagado por el plazo local; nueva revisión de estado")
        for r in [r for r in trabajos if r["vence"] == t]:
            r.update(fase="aplicada", terminada=t)
            if not (perderPrimera and r["id"] == "A" and r["inicio"] == 1):
                mensajes.append(dict(llega=t+2, id=r["id"], fase=r["fase"], origen=r["inicio"],
                                     boot=r["boot"], revision=r["revision"], motivo=""))
            else:
                acciones.append("Se pierde el primer resultado terminal de A")
            acciones.append(f'Se guarda resultado aplicado de {r["id"]}' if registro else
                            f'Termina {r["id"]} sin registro de deduplicación')
        trabajos = [r for r in trabajos if r["vence"] > t]

        def recibir(c):
            nonlocal salida, hasta, revision, aplicaciones, repetidas, conflictos
            acciones.append(f'Llega {c["id"]} ({c["op"]}, {c["duracion"]} s)')
            previo = cache.get(c["id"]) if registro else None
            if previo is not None:
                if previo["huella"] != huella(c):
                    conflictos += 1
                    acciones.append("Conflicto: mismo ID con contenido distinto; no aplicar")
                    mensajes.append(dict(llega=t+2, id=c["id"], fase="conflicto", origen=None, motivo="Contenido distinto"))
                else:
                    repetidas += 1
                    acciones.append(f'Reentrega de {c["id"]}: devolver {previo["fase"]} sin tocar la salida')
                    mensajes.append(dict(llega=t+2, id=c["id"], fase=previo["fase"],
                                         origen=previo["inicio"] if previo["fase"] == "aplicada" else None,
                                         boot=previo["boot"], revision=previo["revision"], motivo=previo["motivo"]))
                return
            if t >= c["expires"]:
                acciones.append("No admitir una orden vencida sin historial; pasado no determinado")
                mensajes.append(dict(llega=t+2, id=c["id"], fase="incierta", origen=None,
                                     motivo="Orden vencida y sin registro de la ejecución anterior"))
                return
            r = dict(id=c["id"], huella=huella(c), fase="preparada", inicio=t, terminada=None,
                     vence=t+1, boot=boot, revision=revision+1, motivo="")
            if registro:
                cache[c["id"]] = r
            acciones.append(f'Registrar preparación de {c["id"]}' if registro else
                            f'Preparar {c["id"]} sin registro de deduplicación')
            revision += 1
            salida = c["op"] == "on"
            hasta = t + c["duracion"] if salida else None
            if salida:
                aplicaciones += 1
            efecto = f"ON hasta {hasta} s" if salida else "OFF"
            acciones.append(f'Aplicar {c["id"]}: {efecto}')
            trabajos.append(r)
            mensajes.append(dict(llega=10 if acuseTardio and c["id"] == "A" and t == 1 else t+1,
                                 id=c["id"], fase="aceptada", origen=None, motivo=""))

        if t == 1:
            recibir(primera)
        if t == reintento:
            servicio.setdefault(segunda["id"], dict(id=segunda["id"], creada=t,
                                fase="pendiente", rango=0, origen=None, motivo=""))
            recibir(segunda)
        if apagar and t == 6:
            servicio["OFF"] = dict(id="OFF", creada=t, fase="pendiente", rango=0, origen=None, motivo="")
            recibir(dict(crear("OFF", "off", 0), expires=6+ttl))
        for m in [m for m in mensajes if m["llega"] == t]:
            s = servicio[m["id"]]
            if m["fase"] == "conflicto":
                acciones.append(f'Servicio recibe conflicto de {m["id"]}; no reemplaza el resultado original')
                continue
            rango = 1 if m["fase"] in ("aceptada", "preparada") else 3
            if s["rango"] == 3 and rango == 3 and (s["fase"], s["origen"], s["motivo"]) != (m["fase"], m["origen"], m["motivo"]):
                acciones.append(f'Resultado terminal incompatible de {m["id"]}: diagnosticar sin sobrescribir')
                continue
            if rango < s["rango"] or (s["rango"] == 3 and rango == 3):
                acciones.append(f'Servicio conserva el resultado de {m["id"]}; ignora respuesta repetida o anterior')
                continue
            s.update(fase=m["fase"], rango=rango, origen=m["origen"], motivo=m["motivo"])
            acciones.append(f'Servicio actualiza {m["id"]}: {m["fase"]}; no escribe el estado actual del equipo')
        mensajes = [m for m in mensajes if m["llega"] > t]
        for s in servicio.values():
            if s["rango"] < 3 and t == s["creada"]+6:
                acciones.append(f'Espera agotada para {s["id"]}; no implica fallo de ejecución')
        solicitudes = [dict(s, vista="Sin confirmación final · espera agotada"
                           if s["rango"] < 3 and t >= s["creada"]+6 else s["fase"])
                       for s in servicio.values()]
        registros = [{k: r[k] for k in ("id", "fase", "inicio", "boot", "revision", "terminada")}
                     for r in cache.values()]
        filas.append(dict(t=t, salida=salida, hasta=hasta, boot=boot, revision=revision,
                          aplicaciones=aplicaciones, repetidas=repetidas, conflictos=conflictos,
                          solicitudes=solicitudes, registros=registros, acciones=acciones))
    return filas


base = simular()
for f in base:
    if f["acciones"]:
        print(f't={f["t"]:2} | ON={f["salida"]} | {". ".join(f["acciones"])}')
assert base[-1]["aplicaciones"] == 1 and base[-1]["repetidas"] == 1
assert base[10]["salida"] and not base[11]["salida"]
assert base[6]["solicitudes"][0]["vista"] == "Sin confirmación final · espera agotada"
assert base[7]["solicitudes"][0]["fase"] == "aplicada"
assert base[10]["solicitudes"][0]["fase"] == "aplicada"
assert simular(registro=False)[-1]["aplicaciones"] == 2
assert simular(identidad="nueva")[-1]["aplicaciones"] == 2
assert simular(identidad="cambiada")[-1]["conflictos"] == 1
assert simular(retencion=3)[-1]["aplicaciones"] == 2
assert simular(reinicio="antes")[-1]["solicitudes"][0]["fase"] == "incierta"
assert simular(reinicio="antes", persistente=False)[-1]["aplicaciones"] == 2
assert simular(reinicio="despues")[-1]["aplicaciones"] == 1
assert not simular(apagar=True)[7]["salida"]
assert simular(reintento=13, ttl=4)[-1]["aplicaciones"] == 1
assert simular(registro=False, reintento=13, ttl=4)[-1]["solicitudes"][0]["fase"] == "incierta"
print("\n15 comprobaciones superadas.")

Caso inicial: A se aplica una vez en t = 1 y conserva límite local a los 11 s. Se pierde su primer resultado. La reentrega de t = 5 recupera el resultado almacenado, recibido a los 7 s; el acuse intermedio de t = 10 no hace retroceder el servicio. Las quince comprobaciones terminan sin errores.

Las decisiones de memoria y persistencia se representan de manera lógica. Para un firmware real se integran con el almacenamiento recuperable del tema 33, límites de capacidad, autenticación, validación y la política del actuador. El ejemplo no promete atomicidad entre memoria y hardware.

11 · Exploración interactiva

Reentregas, resultados y estado actual

Prepará una ejecución y avanzá un segundo o hasta el final. El servicio y el dispositivo se muestran por separado. Las casillas permiten quitar garantías para ver qué problema reaparece; no son opciones recomendadas para un producto.

Una orden A y su segunda entrega

Preparando configuración.

Tiempo simulado0 s
Salida actual del modeloOFF
Límite local vigente—
Revisión actual del dispositivo—
Aplicaciones de ON0
Reentregas reconocidas por el equipo0
Conflictos de identidad detectados0

Conocimiento del servicio sobre A

pendiente

Preparando recorrido.

Solicitudes del servicio

El momento confirmado pertenece a la aplicación histórica, no a la última llegada de una respuesta. «Incierta» conserva la falta de resultado.
IDEstado de conocimientoAplicación confirmadaMotivo

Registro local de identidades

El inicio identifica el intento preparado; solo la fase aplicada confirma su resultado. La revisión asociada al intento no sustituye al estado actual.
IDFaseInicio del intentoRevisión del intento

Historia del recorrido

Se muestran solo instantes con operaciones. El simulador conoce el estado interno; un panel real necesita reportes para conocerlo.
t (s)Qué ocurreSalidaLímite local (s)

Recorrido guiado

  1. Con los valores iniciales, A activa en t = 1 y completa su resultado en t = 2, pero esa respuesta se pierde. La segunda entrega de t = 5 devuelve el resultado guardado. El plazo sigue siendo t = 11.
  2. En t = 6, el servicio agotó su espera de resultado final. En t = 7 recibe «aplicada». A los 10 llega el acuse intermedio y se ignora como anterior; el estado no vuelve a aceptada.
  3. Desmarcá Consultar registro. Hay dos aplicaciones de ON y el plazo se extiende hasta t = 15. Volvé a activarlo y elegí ID nuevo B: también son dos aplicaciones, porque B representa otra solicitud.
  4. Con registro, elegí A con duración cambiada. El equipo detecta un conflicto y no vuelve a aplicar. Ese rechazo de la mutación no demuestra por sí solo cuál fue el resultado perdido de A.
  5. Restaurá A idéntica y reducí la retención a 3 s. El resultado completado en t = 2 se purga al comenzar t = 5. La reentrega vuelve a aplicar y extiende el plazo: la identidad ya no era recordada.
  6. Con retención de 60 s, reiniciá en t = 2. La preparación persistida se recupera como incierta y el reintento no ejecuta de nuevo. Si desmarcás la conservación del registro, se pierde la identidad y la entrega de t = 5 vuelve a aplicar.
  7. Reiniciá en t = 4, con registro conservado. La salida arranca OFF; el reintento devuelve la aplicación histórica de A sin encender otra vez.
  8. Sin reinicio, activá OFF en t = 6. A queda confirmada en el servicio a los 7 s, pero la salida permanece OFF con una revisión posterior. La confirmación de A no reescribe el estado actual.
  9. Con A idéntica, retención de 60 s y segunda entrega en t = 13, fijá admisión antes de t = 4. La primera aplicación fue válida; la repetición recupera su resultado después del vencimiento, sin volver a admitir ni encender.

Para repetir el caso inicial, restaurá registro y conservación, retención 60 s, segunda entrega de A idéntica a los 5 s, admisión hasta 20, sin reinicio ni OFF y con pérdida del primer resultado. Cada nueva preparación inicia una simulación aislada; los IDs no se reutilizan entre equipos reales.

Desafío: ¿por qué una orden vencida sin registro no prueba que nunca actuó?

Porque la identidad pudo haberse perdido por reinicio o retención. El receptor puede asegurar que no la admite ahora, pero no reconstruir un pasado borrado. El laboratorio responde con incertidumbre en ese caso, en lugar de presentar una falsa garantía histórica de «no ejecutada».

12 · Diseñar un intercambio recuperable

Método y confusiones frecuentes

  1. Separá intención, resultado y estado: cada uno tiene identidad, origen y revisión propios.
  2. Fijá el alcance del ID: dispositivo, cliente y reglas de generación; conservarlo en reintentos.
  3. Normalizá el contenido: operación, argumentos y vigencia no cambian bajo la misma identidad.
  4. Reservá la ejecución una sola vez: restricción única, transacción o tarea exclusiva según el entorno.
  5. Devolvé resultados históricos: la repetición no renueva el plazo ni la fecha de aplicación.
  6. Definí etapas y revisiones: no retroceder por acuses viejos y diagnosticar finales incompatibles.
  7. Diseñá reinicio y retención juntos: conservar preparación, resultado y evidencia durante el horizonte necesario.
  8. Expresá incertidumbre: no repetir ni confirmar efectos que no se pueden reconstruir.
  9. Probá las ventanas de corte: antes y después del efecto, del resultado y de la entrega de la respuesta.

Datos de diagnóstico del protocolo

Conviene medir reentregas, conflictos de identidad, comandos inciertos, edad de los pendientes, resultados tardíos y fallas de persistencia. Un contador de comandos «enviados» no permite saber cuántos se aplicaron ni cuántos siguen sin resultado.

«La misma carga con un ID nuevo es un reintento»

El receptor interpreta una identidad nueva como otra intención. Puede volver a aplicar y reiniciar un plazo. Un reintento conserva la identidad original y todo el contenido que define su efecto.

«SET ON siempre es idempotente»

Puede serlo para un booleano, pero no si cada recepción reinicia diez segundos de autorización. La idempotencia se define respecto del efecto completo del contrato, incluidos tiempo y acciones asociadas.

«Un registro en RAM alcanza para deduplicar después de reiniciar»

Solo mientras se conserva. Si se pierde, una identidad antigua puede parecer nueva. Persistencia y recuperación forman parte del protocolo, junto con la capacidad y la retención.

«Guardo antes y después del GPIO; así garantizo exactamente una vez»

Queda una ventana entre las operaciones. Si solo se recupera la preparación, puede faltar evidencia de si el efecto ocurrió. Se necesita un protocolo del actuador, realimentación o una política explícita de incertidumbre y reconciliación.

«La respuesta que llega última es la que vale»

Puede ser un acuse intermedio o un resultado de una revisión anterior. Se validan identidad, transición y revisión; no se usa el orden de llegada como sustituto del orden del protocolo.

«El resultado de ON puede usarse como el estado actual de la salida»

Describe una ejecución histórica. Después pudo vencer el plazo, aplicarse OFF o reiniciarse el equipo. El estado reportado es otra información, con su revisión y antigüedad.

«Si el resultado venció, ya no necesito recordar su identidad»

Puede seguir llegando un reintento. El plazo de retención se diseña con el horizonte de reentregas y admisión. Borrar también la identidad puede permitir otra aplicación o impedir resolver el pasado.

13 · Práctica guiada

Predecí antes de comprobar

1. Un plazo que no se renueva

A activa ON por diez segundos en t = 1. Se repite la misma identidad y contenido en t = 5. ¿Cuándo termina el encendido con un receptor protegido? ¿Y si vuelve a aplicar?

Ver solución

Con deduplicación conserva t = 11. Si vuelve a aplicar diez segundos desde t = 5, lo extiende a t = 15. El segundo caso cambió el efecto de la intención original.

2. Mismo ID y otro argumento

Ya existe A con duración de diez segundos. Llega A con cinco segundos y el resto igual. ¿Se reemplaza la duración almacenada?

Ver solución

No. Se informa conflicto de identidad y se conserva el registro original. Una intención distinta necesita otra solicitud; no se modifica silenciosamente el significado de una identidad ya usada.

3. Un acuse que llegó después

El servicio recibe «aplicada» en t = 7 y «aceptada» en t = 10 para la misma orden. ¿Cuál conserva como resultado más avanzado?

Ver solución

Aplicada. El acuse intermedio tardío no hace retroceder el ciclo. Se comprueban además identidad y revisiones, y se diagnostican respuestas incompatibles.

4. Retención demasiado corta

El resultado se completa en t = 2 y se conserva tres segundos. Se purga antes de procesar entregas de t = 5. ¿Qué riesgo tiene una reentrega de A en ese instante?

Ver solución

La identidad ya no se recuerda y A puede parecer nueva. Si todavía es admisible, se puede volver a aplicar. La retención debe cubrir el horizonte de repetición o existir otra política que impida ese efecto.

5. Solo quedó la preparación

Después de un reinicio se encuentra A preparada, sin resultado terminal. ¿Qué dato falta y qué hace la política conservadora del tema?

Ver solución

Falta evidencia suficiente del resultado de ejecución. Se conserva como incierta, no se devuelve éxito ni se vuelve a ejecutar automáticamente. La resolución requiere el mecanismo de reconciliación previsto para el actuador.

6. Confirmación de ON después de OFF

ON se aplicó en revisión 1 y OFF en revisión 2. Luego llega otra confirmación de ON de revisión 1. ¿Qué se actualiza?

Ver solución

Se puede completar el historial del comando ON. No se reemplaza el estado reportado OFF de revisión posterior. Resultado de comando y estado actual son representaciones distintas.

7. Vencida, pero conocida

A debía admitirse antes de t = 4, se aplicó válidamente a los 1 y su resultado sigue guardado. El mismo comando vuelve a los 13. ¿Puede devolverse ese resultado?

Ver solución

Sí, después de validar identidad, permisos y contenido. Consultar un resultado conocido no es una nueva admisión. No se vuelve a ejecutar ni se renueva la vigencia.

8. Dimensionar identidades

Un dispositivo recibe seis comandos por hora y conserva identidades resueltas durante siete días. ¿Cuántas entradas de ese flujo debe presupuestar, sin contar pendientes adicionales ni sobrecarga?

Ver solución

6 × 24 × 7 = 1008 entradas. El espacio depende de contenido, resultado, índices y metadatos. Los comandos inciertos y las ráfagas requieren capacidad y política propias.

14 · Síntesis

Ideas para recordar

  • Intención, resultado y estado reportado contestan preguntas distintas. Una respuesta histórica no sustituye un reporte actual.
  • Un reintento conserva identidad, operación, argumentos y vigencia. Otro ID representa otra solicitud; el mismo ID con otro contenido es conflicto.
  • La deduplicación devuelve el resultado original sin volver a llamar al actuador ni extender el plazo.
  • Acuses intermedios tardíos no hacen retroceder un resultado; finales incompatibles se diagnostican, no se resuelven por última llegada.
  • Timeout, admisión, duración y retención son plazos independientes. Eliminar el registro demasiado pronto puede permitir repetir efectos.
  • Un registro preparado sin resultado puede quedar incierto tras un reinicio. Una base y un actuador no comparten atomicidad por defecto.
  • En el caso base, A se aplica una vez en t = 1 y apaga en t = 11. Sin deduplicación, la reentrega de t = 5 extiende el plazo a t = 15.
  • El registro SQL demuestra atomicidad del estado lógico dentro de una base; el firmware necesita además su protocolo de ejecución y recuperación.

¿Listo para el tema 39?

Antes de avanzar, definí una orden de tu proyecto con identidad, contenido normalizado, ciclo de resultados y horizonte de retención. Probá perder la primera respuesta y reiniciar entre el efecto y el registro final. Escribí qué puede saberse y qué debe quedar incierto.

En el próximo tema, Gemelo digital del dispositivo, organizamos estado deseado, reportado y diagnóstico en una representación de la plataforma, con versiones y reconciliación para explicar qué está aplicado y qué falta resolver.