← Índice del curso

Sistemas Embebidos · Tema 27 · Taller

Taller: la lógica de un dispositivo real

Cerramos el bloque de máquinas de estados con un producto completo: una cerradura electrónica con teclado. Partimos de los requisitos escritos como se los daría un cliente, los traducimos a eventos y estados, dibujamos el diagrama jerárquico, lo implementamos, lo probamos con propiedades y con miles de eventos al azar, y descubrimos, como pasa en la vida real, un defecto que ningún requisito mencionaba.

01 · Qué vamos a construir

Objetivo, materiales y requisitos

Una cerradura electrónica con teclado para una puerta de oficina. Es un buen caso de estudio porque es chica, pero tiene todo lo que hace difícil la lógica de un dispositivo real: entradas con significado distinto según el contexto (los mismos dígitos abren, programan o se ignoran), datos de contexto (el código, el contador de errores, lo tecleado hasta ahora), varios tiempos, un sensor de puerta y consecuencias de seguridad si algo falla.

Materiales.

  • Un teclado de membrana de 12 teclas (0 a 9, * y #) y un sensor magnético de puerta (contacto de lengüeta).
  • Un solenoide (o cerradura eléctrica) con su transistor y diodo de protección, tres LED (verde, amarillo y rojo) y un zumbador.
  • La placa de siempre y el esqueleto del tema 12, con el entorno de compilación; sin hardware se puede hacer todo el taller en la computadora, que es donde se prueba.

Requisitos. Así los dicta el cliente; cada uno lleva un identificador para poder rastrearlo hasta una prueba:

Requisitos funcionales de la cerradura.
IdRequisito
R1Con el código de 4 dígitos correcto, la puerta se destraba durante 5 s (solenoide energizado y LED verde) y vuelve a trabarse sola.
R2Cada dígito reinicia un plazo de 5 s: si vence antes de completar el código, se descarta lo tecleado.
R3La tecla * cancela la entrada en curso.
R4Un código incorrecto da un pitido de error. Tras 3 errores seguidos, la cerradura se bloquea 60 s (LED rojo) y el teclado se ignora.
R5Un acierto pone en cero el contador de errores; el fin del bloqueo también.
R6Con # seguido del código vigente se entra al modo de programación: se teclea el código nuevo y se lo repite. Si coinciden, se guarda; si no, queda el anterior. Plazo de 10 s por paso.
R7Si la puerta se abre sin autorización (con la cerradura asegurada), suena la alarma hasta que se cierra.
R8Si la puerta se abre tras una autorización y queda abierta más de 30 s, suena la alarma hasta que se cierra.
R9El solenoide solo está energizado mientras la puerta está autorizada; el LED amarillo indica que se están ingresando datos.

Este taller no tiene un listado de registros: los módulos de hardware (teclado, sensor, solenoide, zumbador) se tratan como una interfaz, como en los temas 23 y 25. Lo que se construye aquí es la lógica, que es donde está la dificultad y donde están los errores.

02 · Paso 1

De los requisitos al modelo

Un requisito dicho en lenguaje común contiene pistas de eventos, estados y datos. El primer trabajo es extraerlas, subrayando el texto:

Eventos, estados y datos que salen de los requisitos.
CategoríaElementosDe dónde sale
Eventos de entradaDIGITO(d), ASTERISCO, NUMERAL, P_ABRE, P_CIERRATeclado y sensor de puerta (R1–R3, R6–R8)
Eventos de tiempoTIMEOUT (con un plazo distinto según el estado)«5 s», «60 s», «10 s», «30 s» (R1, R2, R4, R6, R8)
Estados que se distinguen por cómo reaccionanREPOSO, CLAVE (ingresando para abrir), CLAVE_PROG (ingresando para programar), PROG_NUEVO, PROG_CONFIRMAR, ABIERTA, PUERTA_ABIERTA, ALARMA_PUERTA, BLOQUEADACada uno responde distinto a los mismos dígitos o a la puerta
Datos de contextoEl código vigente, el código nuevo, los dígitos tecleados, el contador de errores«código», «3 errores seguidos», «repetirlo» (R4–R6)

Algunas decisiones de modelado que conviene destacar:

  • Los dígitos tecleados son datos, no estados. Hacer un estado por cada dígito parcial (UN_DIGITO, DOS_DIGITOS...) multiplicaría los estados sin ganar claridad. Se usa un solo estado CLAVE con un buffer y una guarda («¿completó los 4?»). Es la «extensión» de la máquina de estados que mencionó el tema 24.
  • «Ingresando para abrir» y «ingresando para programar» son estados distintos, porque el mismo código correcto lleva a destinos distintos (ABIERTA o PROG_NUEVO). Comparten el manejador de dígitos: lo único que cambia es el destino al acertar.
  • La puerta forzada y la puerta que se queda abierta son situaciones distintas (R7 y R8) pero la señal es la misma, P_ABRE: lo que la distingue es el estado en que llega. Cuando la cerradura estaba asegurada, es un intento de entrada; cuando estaba autorizada, es el uso normal.
  • El tiempo es un evento, con un plazo que depende del estado y se arma al entrar (tema 26).

Mirando el modelo aparece enseguida una repetición característica. La tecla * debe cancelar la entrada desde cualquiera de los cuatro estados de ingreso de datos, y la apertura de la puerta debe disparar la alarma desde cualquiera de los seis estados en que la cerradura está asegurada (REPOSO, BLOQUEADA y los cuatro de ingreso). En una máquina plana, serían diez transiciones casi iguales. Es el caso de manual para la jerarquía del tema 26.

03 · Paso 2

El diagrama jerárquico

Dos superestados agrupan el comportamiento común: ENTRADA (los cuatro estados en que se están tecleando dígitos) y ASEGURADA (todos los estados en que la puerta debe permanecer cerrada, que incluyen a ENTRADA). Quedan fuera los tres estados en que la puerta está autorizada o ya se abrió (ABIERTA, PUERTA_ABIERTA y ALARMA_PUERTA).

Diagrama de estados jerárquico de la cerradura El superestado ASEGURADA contiene REPOSO, BLOQUEADA y el superestado ENTRADA, que a su vez contiene CLAVE, CLAVE_PROG, PROG_NUEVO y PROG_CONFIRMAR. Fuera de ASEGURADA están ABIERTA, PUERTA_ABIERTA y ALARMA_PUERTA. Desde REPOSO, un dígito lleva a CLAVE y el numeral a CLAVE_PROG. Desde CLAVE, el código correcto lleva a ABIERTA. Desde CLAVE_PROG, el código correcto lleva a PROG_NUEVO, que lleva a PROG_CONFIRMAR, que vuelve a REPOSO. Desde ABIERTA, la puerta que se abre lleva a PUERTA_ABIERTA y, tras 30 segundos, a ALARMA_PUERTA. Desde ASEGURADA, la puerta que se abre lleva a ALARMA_PUERTA. REPOSOBLOQUEADA CLAVECLAVE_PROG PROG_NUEVOPROG_CONFIRMAR ABIERTAPUERTA_ABIERTAALARMA_PUERTA ASEGURADA — P_ABRE → ALARMA_PUERTA (una sola vez) ENTRADA — ASTERISCO → REPOSO (una sola vez) dígito# código correctocódigo correcto 4 dígitoscoinciden → REPOSO P_ABRE30 s sin cerrar forzada Los errores, el tiempo vencido y el bloqueo (no dibujados) están en la tabla.

El diagrama muestra el recorrido principal; la tabla de manejadores es la que contiene todas las transiciones, y es el documento que se revisa celda por celda. Hay 18 en total, de las cuales 2 pertenecen a los superestados:

Los manejadores de la cerradura. Una transición «interna» no sale ni entra del estado.
EnEventoGuardaDestinoAcción
REPOSODIGITO—CLAVEbuffer = [d]
REPOSONUMERAL—CLAVE_PROGbuffer = []
CLAVE y CLAVE_PROGDIGITObuffer incompleto(interna)agregar; rearmar 5 s
CLAVEDIGITO4º dígito, correctoABIERTAfallos = 0; pitido de acierto
CLAVE_PROGDIGITO4º dígito, correctoPROG_NUEVOfallos = 0; pitido de acierto
CLAVE y CLAVE_PROGDIGITO4º dígito, incorrectoREPOSO (o BLOQUEADA si fallos = 3)fallos++; pitido de error
CLAVE, CLAVE_PROG, PROG_NUEVO, PROG_CONFIRMARTIMEOUT—REPOSOdescartar lo tecleado
PROG_NUEVODIGITObuffer incompleto(interna)agregar; rearmar 10 s
PROG_NUEVODIGITO4º dígitoPROG_CONFIRMARguardar como código nuevo
PROG_CONFIRMARDIGITObuffer incompleto(interna)agregar; rearmar 10 s
PROG_CONFIRMARDIGITO4º dígitoREPOSOsi coincide, código = nuevo; pitido de acierto o de error
ABIERTATIMEOUT5 sREPOSO—
ABIERTAP_ABRE—PUERTA_ABIERTA—
PUERTA_ABIERTAP_CIERRA—REPOSO—
PUERTA_ABIERTATIMEOUT30 sALARMA_PUERTA—
ALARMA_PUERTAP_CIERRA—REPOSO—
BLOQUEADATIMEOUT60 sREPOSOfallos = 0
ENTRADA (superestado)ASTERISCO—REPOSOdescartar lo tecleado
ASEGURADA (superestado)P_ABRE—ALARMA_PUERTA—

(Las filas agrupadas cuentan por una transición cada una en la tabla del código; el programa de Python de este tema contabiliza las celdas: 9 estados hoja por 6 clases de evento son 54; 16 tienen manejador propio, 10 se resuelven en un superestado y 28 ignoran el evento, por decisión.) Sin los superestados habría que escribir 26 transiciones en lugar de 18.

04 · Paso 3

Salidas, tiempos y datos

Las salidas físicas se asignan siguiendo la regla del tema 24: lo que es una condición sostenida va en las acciones de entrada y salida de un estado, y las órdenes puntuales en la transición. Con la jerarquía, además, una acción de entrada o de salida de un superestado se ejecuta solo cuando se cruza su frontera:

Acciones de entrada y de salida, y temporizadores de cada estado.
EstadoAl entrarAl salirPlazo (evento TIMEOUT)
ENTRADA (superestado)LED amarillo encendidoLED amarillo apagado—
CLAVE, CLAVE_PROG——5 s, reiniciado en cada dígito
PROG_NUEVO, PROG_CONFIRMAR——10 s, reiniciado en cada dígito
ABIERTASolenoide energizado; LED verde encendidoSolenoide apagado; LED verde apagado5 s
PUERTA_ABIERTA——30 s
ALARMA_PUERTAAlarma sonoraSilencio—
BLOQUEADALED rojo encendidoLED rojo apagado60 s

Dos propiedades salen de estas decisiones por construcción, y son justamente las que el requisito R9 pide. El solenoide está energizado si y solo si el estado es ABIERTA: se enciende en una sola acción de entrada y se apaga en una sola acción de salida, de modo que ningún camino (ni uno futuro) puede dejarlo encendido por olvido. Y el LED amarillo está encendido si y solo si el estado está dentro de ENTRADA: pasar de CLAVE_PROG a PROG_NUEVO no cruza la frontera del superestado, y el LED sigue encendido sin que ninguna transición lo toque.

Los temporizadores siguen una regla simple: al salir de cualquier estado se cancela su temporizador, y al entrar se arma el del estado nuevo. Así, un plazo no puede sobrevivir al estado que lo armó. Y los datos de contexto tienen dueño: el buffer lo escriben los estados de ingreso, el contador de errores lo manejan las transiciones del ingreso y del bloqueo, y el código vigente solo cambia al final de una programación exitosa.

05 · Paso 4

La implementación en C

La estructura combina lo de los temas 25 y 26: manejadores como funciones, una tabla que los asocia con un par (estado, clase de evento), búsqueda hacia arriba por los superestados, y un único lugar donde se cambia de estado, con las acciones de salida y de entrada. Primero los tipos:

/* src/cerradura.h */
#define LARGO 4
typedef enum { E_REPOSO, E_CLAVE, E_CLAVE_PROG, E_PROG_NUEVO, E_PROG_CONFIRMAR,
               E_ABIERTA, E_PUERTA_ABIERTA, E_ALARMA_PUERTA, E_BLOQUEADA,
               E_ENTRADA, E_ASEGURADA, E_CANT } Estado;                 /* los dos últimos son superestados */
#define NINGUNO 0xFF

typedef enum { CL_DIGITO, CL_ASTERISCO, CL_NUMERAL, CL_P_ABRE, CL_P_CIERRA, CL_TIMEOUT, CL_CANT } Clase;
typedef struct { Clase clase; uint8_t digito; } Evento;                 /* 'digito' vale solo si clase == CL_DIGITO */

typedef struct {
    Estado  estado;
    uint8_t buf[LARGO];   uint8_t n;                                    /* lo tecleado hasta ahora */
    uint8_t codigo[LARGO], nuevo[LARGO];                                /* código vigente y código en programación */
    uint8_t fallos;                                                     /* errores seguidos */
} Cerradura;

void cerradura_iniciar(Cerradura *c);
void cerradura_evento(Cerradura *c, Evento e);                          /* un evento, hasta completar */

/* El resultado de un manejador: a dónde ir; 'interna' = quedarse sin salir ni entrar */
typedef struct { Estado destino; bool interna; } Resultado;
typedef Resultado (*Manejador)(Cerradura *c, Evento e);

La cerradura es una estructura con todo su estado, no un conjunto de variables globales: se puede crear una de prueba en la computadora, o dos en el mismo programa, sin interferencias. Los manejadores son funciones pequeñas. El más interesante es el que trata los dígitos del ingreso, compartido por CLAVE y CLAVE_PROG:

/* Comparación en tiempo constante: no revela, por cuánto tarda, cuántos dígitos coinciden */
static bool iguales(const uint8_t *a, const uint8_t *b) {
    uint8_t diferencia = 0;
    for (unsigned i = 0; i < LARGO; i++) diferencia |= (uint8_t)(a[i] ^ b[i]);
    return diferencia == 0;
}

static Resultado h_clave_digito(Cerradura *c, Evento e) {
    c->buf[c->n++] = e.digito;
    if (c->n < LARGO) { rearmar_plazo(); return (Resultado){ c->estado, true }; }     /* faltan dígitos: transición interna */

    bool ok = iguales(c->buf, c->codigo);
    c->n = 0;
    if (ok) {
        c->fallos = 0;
        beep_ok();
        return (Resultado){ c->estado == E_CLAVE ? E_ABIERTA : E_PROG_NUEVO, false };
    }
    c->fallos++;
    beep_error();
    return (Resultado){ c->fallos >= MAX_FALLOS ? E_BLOQUEADA : E_REPOSO, false };
}

static Resultado h_prog_confirmar_digito(Cerradura *c, Evento e) {
    c->buf[c->n++] = e.digito;
    if (c->n < LARGO) { rearmar_plazo(); return (Resultado){ c->estado, true }; }
    bool ok = iguales(c->buf, c->nuevo);
    c->n = 0;
    if (ok) { memcpy(c->codigo, c->nuevo, LARGO); guardar_codigo(c->codigo); beep_ok(); }
    else    { beep_error(); }
    return (Resultado){ E_REPOSO, false };                                 /* en los dos casos vuelve a REPOSO */
}

La tabla asocia cada par con su manejador. Las dos últimas filas son las de los superestados, escritas una sola vez; el despachador sube por los padres hasta encontrar quién responde:

typedef struct { Estado donde; Clase clase; Manejador fn; } Entrada;

static const Entrada H[] = {
    { E_REPOSO,         CL_DIGITO,    h_reposo_digito },    { E_REPOSO,         CL_NUMERAL,  h_reposo_numeral },
    { E_CLAVE,          CL_DIGITO,    h_clave_digito  },    { E_CLAVE,          CL_TIMEOUT,  h_a_reposo },
    { E_CLAVE_PROG,     CL_DIGITO,    h_clave_digito  },    { E_CLAVE_PROG,     CL_TIMEOUT,  h_a_reposo },
    { E_PROG_NUEVO,     CL_DIGITO,    h_prog_nuevo_digito },{ E_PROG_NUEVO,     CL_TIMEOUT,  h_a_reposo },
    { E_PROG_CONFIRMAR, CL_DIGITO,    h_prog_confirmar_digito }, { E_PROG_CONFIRMAR, CL_TIMEOUT, h_a_reposo },
    { E_ABIERTA,        CL_TIMEOUT,   h_a_reposo },         { E_ABIERTA,        CL_P_ABRE,   h_a_puerta_abierta },
    { E_PUERTA_ABIERTA, CL_P_CIERRA,  h_a_reposo },         { E_PUERTA_ABIERTA, CL_TIMEOUT,  h_a_alarma },
    { E_ALARMA_PUERTA,  CL_P_CIERRA,  h_a_reposo },         { E_BLOQUEADA,      CL_TIMEOUT,  h_fin_bloqueo },
    { E_ENTRADA,        CL_ASTERISCO, h_a_reposo },         /* vale para los cuatro estados de ingreso */
    { E_ASEGURADA,      CL_P_ABRE,    h_a_alarma },         /* vale para los seis estados asegurados */
};

void cerradura_evento(Cerradura *c, Evento e) {
    for (Estado s = c->estado; s != NINGUNO; s = PADRE[s]) {              /* sube por los superestados */
        for (unsigned i = 0; i < sizeof H / sizeof H[0]; i++) {
            if (H[i].donde == s && H[i].clase == e.clase) {
                Resultado r = H[i].fn(c, e);
                if (!r.interna) ir_a(c, s, r.destino);                    /* salidas, acción, entradas: tema 26 */
                return;
            }
        }
    }
    /* nadie lo maneja: se ignora */
}

Y las acciones de entrada y de salida, que son la clave de las propiedades de R9. Obsérvese la salida del superestado ENTRADA: además de apagar el LED, borra los dígitos tecleados (el motivo está en el apartado de pruebas):

static void entrar_abierta(Cerradura *c)  { solenoide(true);  led_verde(true);  armar_plazo(5000);  }
static void salir_abierta(Cerradura *c)   { solenoide(false); led_verde(false); }
static void entrar_bloqueada(Cerradura *c){ led_rojo(true);   armar_plazo(60000); }
static void salir_bloqueada(Cerradura *c) { led_rojo(false);  }
static void entrar_alarma(Cerradura *c)   { alarma(true);     }
static void salir_alarma(Cerradura *c)    { alarma(false);    }
static void entrar_entrada(Cerradura *c)  { led_amarillo(true); }
static void salir_entrada(Cerradura *c)   { led_amarillo(false);
                                            borrar(c->buf, sizeof c->buf);  c->n = 0;      /* ningún dígito sobrevive */
                                            borrar(c->nuevo, sizeof c->nuevo); }

/* 'borrar' usa memset_s o una escritura volátil, para que el compilador no elimine la limpieza */

El módulo no incluye nada del fabricante: solenoide(), led_verde(), beep_ok() y las demás son la interfaz con el hardware, que en las pruebas se reemplaza por una versión que solo registra qué se llamó (tema 25). El teclado, el sensor de puerta y el temporizador producen Evento y los publican en la cola del tema 25; el bucle principal los despacha de a uno.

06 · Paso 5

Las pruebas

La estrategia combina tres niveles, de lo más preciso a lo más amplio:

  1. Una prueba por celda, con el estado forzado y el hardware falso (tema 25): para cada una de las 54 celdas (9 estados hoja por 6 clases de evento) se verifica el estado resultante y las salidas registradas. Es lo que garantiza que la tabla hace lo que dice.
  2. Escenarios de requisito. Una secuencia por requisito, rastreable hasta él: R1 es «1, 2, 3, 4, 5 s», R4 es «tres códigos errados, un correcto durante el bloqueo, 60 s, un correcto», y así. Si un requisito cambia, se sabe qué prueba tocar.
  3. Propiedades con eventos al azar. Se alimenta la máquina con cientos de miles de eventos generados al azar (mezclando ruido con ráfagas del código correcto, para llegar a los estados poco frecuentes) y después de cada evento se comprueban invariantes: afirmaciones que deben ser ciertas siempre. Acá es donde se encuentran los defectos que nadie pensó en probar.
/* test/test_cerradura.c: propiedades que deben cumplirse después de CADA evento */
static void verificar_invariantes(const Cerradura *c, const Salidas *s) {
    assert(s->solenoide == (c->estado == E_ABIERTA));                      /* R9: solo autorizada */
    assert(s->alarma    == (c->estado == E_ALARMA_PUERTA));
    assert(s->rojo      == (c->estado == E_BLOQUEADA));
    assert(s->amarillo  == dentro_de(c->estado, E_ENTRADA));                /* amarillo ⇔ dentro del superestado */
    if (!dentro_de(c->estado, E_ENTRADA)) {
        assert(c->n == 0);                                                  /* R10: ningún dígito fuera de la entrada */
        assert(todo_cero(c->nuevo, LARGO));
    }
}

int main(void) {
    Cerradura c; Salidas s;
    cerradura_iniciar(&c);
    for (unsigned long i = 0; i < 400000; i++) {
        Evento e = evento_al_azar(&c);                                   /* ruido y ráfagas del código vigente */
        cerradura_evento(&c, e);
        s = salidas_falsas();
        verificar_invariantes(&c, &s);
    }
    return 0;
}

Y la parte de la historia que justifica todo el esfuerzo. El requisito R10 ( «ningún dígito tecleado permanece en memoria fuera de la entrada de datos») no estaba en la lista del cliente: apareció al escribir las invariantes, preguntando «¿qué debería ser cierto de la memoria cuando nadie está tecleando?». Con la primera versión del modelo, en la que la acción de salida de ENTRADA solo apagaba el LED, el programa de Python de este tema detecta la violación en cuanto una puerta se fuerza con dígitos a medias: el estado pasa a ALARMA_PUERTA pero el buffer conserva los dígitos de un código a medio tipear. Con el código de un usuario real en memoria, un volcado o una falla pueden filtrarlo.

La corrección es de una línea y de las que el diseño jerárquico hace posibles: la acción de salida del superestado ENTRADA borra los buffers. No importa por qué camino se abandone la entrada de datos (un acierto, un error, la tecla *, un plazo vencido, una puerta forzada o cualquier transición que se agregue en el futuro): la limpieza ocurre. Volviendo a correr los eventos al azar, las violaciones pasan a cero. Es el valor de una acción de salida atada a una frontera, y es lo que una máquina plana habría obligado a repetir en cada transición, con la probabilidad de olvidar una.

07 · Paso 6

Lo que un dispositivo real exige

La lógica está completa, pero un producto que cuida una puerta tiene exigencias que no entran en una máquina de estados y que hay que decidir antes de mandar a fabricar:

Decisiones de un dispositivo real, más allá de la lógica.
TemaPreguntaDecisión razonable
Falla de energía¿Qué hace la cerradura sin alimentación?Depende del uso: una oficina suele quererla trabada (falla segura para el patrimonio) y una salida de emergencia, destrabada (falla segura para las personas). Se elige el tipo de solenoide en consecuencia, y se agrega una llave mecánica
Reinicio del microcontrolador¿Se reinicia el contador de errores?Si el contador vive solo en RAM, un atacante puede cortarle la alimentación después de dos errores y volver a empezar. Se guarda en memoria no volátil (tema 40) y se limita el número de escrituras
Persistencia del código¿Dónde se guarda y cómo?En memoria no volátil, con comprobación de integridad; si está corrupta, el equipo vuelve a un código de fábrica conocido y lo avisa (o queda bloqueado hasta un restablecimiento físico)
Comparación del código¿Se puede adivinar midiendo el tiempo?Comparación en tiempo constante (la función iguales), sin salida anticipada
Fuerza bruta¿Cuántos intentos permite el bloqueo?10 000 códigos con 3 errores por minuto serían 3333 minutos, casi 56 horas, si el atacante tiene paciencia; se puede endurecer con bloqueos crecientes y un registro de intentos
Rebote y repetición de teclas¿Un rebote cuenta como dos dígitos?Antirrebote y detección de flanco antes de que la tecla sea un evento (tema 24)
Atascos y vigilancia¿Qué pasa si el programa se cuelga?Watchdog (tema 38): un reinicio devuelve al estado seguro, con el solenoide apagado desde el primer instante (tema 9)
Registro¿Se guardan los intentos?Sí, en un buffer circular con instante y resultado, nunca con los dígitos tecleados

Estas decisiones se anotan como requisitos adicionales (R11 en adelante) y entran al mismo circuito: cada una se traduce a eventos, estados o propiedades y a una prueba. Por ejemplo, «el contador de errores sobrevive a un reinicio» agrega una acción (guardar en memoria no volátil cada vez que cambia) y un caso de prueba (reiniciar el modelo con el contador restaurado y verificar que la tercera falla siga bloqueando).

08 · Comprobar que funciona

Verificación y diagnóstico

Pruebas de aceptación en el equipo, con su requisito.
Req.PruebaResultado esperado
R1Teclear 1-2-3-4 y medir con el analizador el pin del solenoideEnergizado durante 5 s ±1 tick y luego apagado; LED verde en sincronía
R2Teclear dos dígitos y esperar 6 s; teclear dos másLos primeros se descartaron: los dos siguientes no completan nada
R3Teclear tres dígitos, *, y luego los cuatro del códigoAbre (no fue necesario terminar el primer intento)
R4, R5Tres códigos errados seguidos; luego el correcto; después de 60 s, el correctoTres pitidos de error y LED rojo; el correcto se ignora durante el bloqueo; tras 60 s, abre
R6#, el código, uno nuevo dos veces; probar el nuevo y el viejoEl nuevo abre y el viejo no; y con una confirmación que no coincide, el viejo sigue valiendo
R7Abrir la puerta con la cerradura aseguradaAlarma inmediata, que se detiene al cerrar
R8Autorizar, abrir y dejar abierto 35 sEl solenoide se apaga al abrir; a los 30 s suena la alarma hasta cerrar
R9Observar el solenoide durante todas las pruebas anterioresEnergizado solo en el tramo de apertura autorizada
R10Con un depurador, forzar la puerta con dos dígitos tecleados y leer el bufferEl buffer está en cero
ReinicioCortar la alimentación en medio del bloqueo y volver a conectarArranca asegurada, con el solenoide apagado, sin destello; el contador restaurado si se implementó
Síntomas frecuentes y qué revisar.
SíntomaCausa probableQué hacer
El primer código tras un bloqueo siempre fallaEl contador de errores no se puso en cero al terminar el bloqueoLa acción del TIMEOUT de BLOQUEADA debe hacer fallos = 0
Tecleando rápido se cuentan dígitos de másRebote del teclado convertido en eventosAntirrebote y flanco antes de la cola de eventos
El solenoide queda energizado después de abrir la puertaFalta la acción de salida de ABIERTA, o hay una transición que se saltea la salidaTodo cambio de estado por la función única ir_a
A veces abre con un código a mediasEl buffer conserva dígitos de un intento anteriorLimpieza en la acción de salida de ENTRADA
La alarma no se detiene al cerrarEl evento P_CIERRA se genera por nivel o con reboteEvento por flanco, con filtro, desde el sensor
Un reinicio durante el bloqueo permite intentar de nuevoEl contador de errores solo está en RAMPersistirlo en memoria no volátil

09 · Analogía con programación

Probar el modelo en Python

El programa implementa el mismo modelo jerárquico que el diagrama y la tabla: manejadores por (estado, clase de evento), búsqueda hacia arriba por los superestados, y acciones de salida y de entrada que se ejecutan en el orden del tema 26. Tiene tres partes: las cinco secuencias de requisito (con cada transición y las acciones que produce), la verificación con cientos de miles de eventos al azar comprobando cinco propiedades después de cada uno —ejecutada dos veces, con el modelo ingenuo y con el corregido— y el balance del tamaño del modelo.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar. La verificación con 200 000 pasos tarda varios segundos.

# Cerradura electrónica con teclado: máquina jerárquica con datos de contexto (código, intentos, buffer).
PADRE = {"REPOSO": "ASEGURADA", "BLOQUEADA": "ASEGURADA", "ENTRADA": "ASEGURADA",
         "CLAVE": "ENTRADA", "CLAVE_PROG": "ENTRADA", "PROG_NUEVO": "ENTRADA", "PROG_CONFIRMAR": "ENTRADA"}
HOJAS = ["REPOSO", "CLAVE", "CLAVE_PROG", "PROG_NUEVO", "PROG_CONFIRMAR", "ABIERTA", "PUERTA_ABIERTA", "ALARMA_PUERTA", "BLOQUEADA"]
CLASES = ["DIGITO", "ASTERISCO", "NUMERAL", "P_ABRE", "P_CIERRA", "TIMEOUT"]
ENTRADA = {"ABIERTA": ["solenoide_on", "verde_on"], "BLOQUEADA": ["rojo_on"], "ALARMA_PUERTA": ["alarma_on"], "ENTRADA": ["amarillo_on"]}
SALIDA  = {"ABIERTA": ["solenoide_off", "verde_off"], "BLOQUEADA": ["rojo_off"], "ALARMA_PUERTA": ["alarma_off"], "ENTRADA": ["amarillo_off"]}

def configurar(corregido):
    """Versión ingenua: al salir de la entrada de datos solo se apaga el LED amarillo.
    Versión corregida: la acción de salida del superestado ENTRADA también borra los dígitos tecleados (por cualquier camino)."""
    SALIDA["ENTRADA"] = ["amarillo_off", "borrar_buffer", "borrar_nuevo"] if corregido else ["amarillo_off"]
configurar(True)
MAX_FALLOS, LARGO = 3, 4

def nuevo_contexto():
    return dict(estado="REPOSO", buf=[], fallos=0, codigo=[1, 2, 3, 4], nuevo=[], salidas=set())

def cadena(x):
    c = [x]
    while c[-1] in PADRE: c.append(PADRE[c[-1]])
    return c

# --- Manejadores: (estado, clase) -> función(ctx, evento) -> (destino o None, acciones). None = transición interna ----
def h_reposo_digito(c, d):     c["buf"] = [d]; return "CLAVE", []
def h_reposo_numeral(c, _):    c["buf"] = []; return "CLAVE_PROG", []

def h_clave_digito(c, d):
    c["buf"].append(d)
    if len(c["buf"]) < LARGO: return None, ["rearmar"]
    ok = c["buf"] == c["codigo"]; c["buf"] = []
    if ok:
        c["fallos"] = 0
        return ("ABIERTA" if c["estado"] == "CLAVE" else "PROG_NUEVO"), ["beep_ok"]
    c["fallos"] += 1
    return ("BLOQUEADA" if c["fallos"] >= MAX_FALLOS else "REPOSO"), ["beep_error"]

def h_prog_nuevo_digito(c, d):
    c["buf"].append(d)
    if len(c["buf"]) < LARGO: return None, ["rearmar"]
    c["nuevo"], c["buf"] = list(c["buf"]), []
    return "PROG_CONFIRMAR", []

def h_prog_confirmar_digito(c, d):
    c["buf"].append(d)
    if len(c["buf"]) < LARGO: return None, ["rearmar"]
    ok = c["buf"] == c["nuevo"]; c["buf"] = []
    if ok: c["codigo"] = list(c["nuevo"])
    return "REPOSO", ["beep_ok" if ok else "beep_error"]

def a_reposo(c, e):
    c["buf"] = []
    return "REPOSO", []

H = {
    ("REPOSO", "DIGITO"): h_reposo_digito, ("REPOSO", "NUMERAL"): h_reposo_numeral,
    ("CLAVE", "DIGITO"): h_clave_digito, ("CLAVE", "TIMEOUT"): a_reposo,
    ("CLAVE_PROG", "DIGITO"): h_clave_digito, ("CLAVE_PROG", "TIMEOUT"): a_reposo,
    ("PROG_NUEVO", "DIGITO"): h_prog_nuevo_digito, ("PROG_NUEVO", "TIMEOUT"): a_reposo,
    ("PROG_CONFIRMAR", "DIGITO"): h_prog_confirmar_digito, ("PROG_CONFIRMAR", "TIMEOUT"): a_reposo,
    ("ABIERTA", "TIMEOUT"): lambda c, e: ("REPOSO", []), ("ABIERTA", "P_ABRE"): lambda c, e: ("PUERTA_ABIERTA", []),
    ("PUERTA_ABIERTA", "P_CIERRA"): lambda c, e: ("REPOSO", []), ("PUERTA_ABIERTA", "TIMEOUT"): lambda c, e: ("ALARMA_PUERTA", []),
    ("ALARMA_PUERTA", "P_CIERRA"): lambda c, e: ("REPOSO", []),
    ("BLOQUEADA", "TIMEOUT"): lambda c, e: ("REPOSO", ["fallos_a_cero"]),
    # comportamiento común, escrito UNA vez en los superestados
    ("ENTRADA", "ASTERISCO"): a_reposo,
    ("ASEGURADA", "P_ABRE"): lambda c, e: ("ALARMA_PUERTA", []),
}

def clase(ev):
    return "DIGITO" if isinstance(ev, int) else {"*": "ASTERISCO", "#": "NUMERAL"}.get(ev, ev)

def buscar(estado, cl):
    s = estado
    while s is not None:
        if (s, cl) in H: return s, H[(s, cl)]
        s = PADRE.get(s)
    return None, None

def procesar(c, ev):
    """Un evento, hasta completar. Devuelve las acciones ejecutadas (salidas, transición, entradas) en orden."""
    donde, fn = buscar(c["estado"], clase(ev))
    if fn is None: return []
    destino, acciones = fn(c, ev)
    if destino is None:                                    # transición interna: ni se sale ni se entra
        return list(acciones)
    lca = next((x for x in cadena(donde)[1:] if x in cadena(destino)), None)
    registro = []
    for x in cadena(c["estado"]):
        if x == lca: break
        registro += SALIDA.get(x, [])
    for a in acciones:
        registro.append(a)
        if a == "fallos_a_cero": c["fallos"] = 0
    for a in list(registro):
        if a == "borrar_buffer": c["buf"] = []
        elif a == "borrar_nuevo": c["nuevo"] = []
    c["estado"] = destino
    for x in reversed(cadena(destino)):
        if x == lca or (lca and x in cadena(lca)): continue
        registro += ENTRADA.get(x, [])
    for a in registro:
        if a.endswith("_on"): c["salidas"].add(a[:-3])
        elif a.endswith("_off"): c["salidas"].discard(a[:-4])
    return registro

def correr(titulo, eventos):
    c = nuevo_contexto()
    print(titulo)
    for ev in eventos:
        antes = c["estado"]
        acc = [a for a in procesar(c, ev) if not a.startswith("borrar")]
        print(f"  {str(ev):10s} {antes:15s} -> {c['estado']:15s} {acc if acc else ''}")
    print(f"  código vigente: {c['codigo']}  fallos: {c['fallos']}\n")

print("Parte 1: la cerradura en acción")
correr("A. Apertura normal y trabado automático", [1, 2, 3, 4, "TIMEOUT"])
correr("B. Tres errores seguidos, bloqueo, y recuperación", [9, 9, 9, 9, 1, 1, 1, 1, 2, 2, 2, 2, 1, 2, 3, 4, "TIMEOUT", 1, 2, 3, 4])
correr("C. Cambio de código", ["#", 1, 2, 3, 4, 5, 6, 7, 8, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8])
correr("D. Puerta forzada, y puerta que se queda abierta", ["P_ABRE", "P_CIERRA", 1, 2, 3, 4, "P_ABRE", "TIMEOUT", "P_CIERRA"])
correr("E. Cancelar con asterisco", [1, 2, "*", "*"])

# ---- Parte 2: verificación con 200 000 eventos al azar y propiedades que deben cumplirse siempre ----------
def invariantes(c):
    s, sal = c["estado"], c["salidas"]
    return [("solenoide solo en ABIERTA", ("solenoide" in sal) == (s == "ABIERTA")),
            ("alarma solo en ALARMA_PUERTA", ("alarma" in sal) == (s == "ALARMA_PUERTA")),
            ("LED amarillo solo durante la entrada de datos", ("amarillo" in sal) == ("ENTRADA" in cadena(s))),
            ("LED rojo solo bloqueada", ("rojo" in sal) == (s == "BLOQUEADA")),
            ("sin dígitos tecleados fuera de la entrada de datos", "ENTRADA" in cadena(s) or (c["buf"] == [] and c["nuevo"] == []))]

def eventos_al_azar(n, semilla=2026):
    """Mezcla de ruido y de ráfagas con sentido: el código correcto vigente (se necesita para llegar a ABIERTA y a la programación)."""
    c = nuevo_contexto()                                     # solo para leer el código vigente al generar
    cola = []
    while True:
        semilla = (semilla * 1103515245 + 12345) & 0x7FFFFFFF
        r = (semilla >> 16) % 100
        if r < 30:   cola = [(semilla >> 8) % 10]
        elif r < 34: cola = ["*"]
        elif r < 40: cola = ["#"]
        elif r < 44: cola = ["P_ABRE"]
        elif r < 56: cola = ["P_CIERRA"]
        elif r < 68: cola = ["TIMEOUT"]
        elif r < 90: cola = [("CODIGO", 0)]                  # marcador: el valor real se resuelve al ejecutar
        else:        cola = [("CODIGO", 1)]
        for e in cola:
            yield e
            n -= 1
            if n <= 0: return

def verificar(corregido):
    configurar(corregido)
    c = nuevo_contexto()
    visitados, viol, aperturas, bloqueos, programaciones, cola = {}, {}, 0, 0, 0, []
    cambios_codigo, total = 0, 0
    for ev in eventos_al_azar(200000):
        if isinstance(ev, tuple):                            # ráfaga: los 4 dígitos del código vigente (o uno errado)
            dig = list(c["codigo"]) if ev[1] == 0 else [(d + 1) % 10 for d in c["codigo"]]
            cola = dig
        else:
            cola = [ev]
        for e in cola:
            antes, codigo_antes = c["estado"], list(c["codigo"])
            total += 1
            procesar(c, e)
            visitados[c["estado"]] = visitados.get(c["estado"], 0) + 1
            if c["estado"] == "ABIERTA" and antes != "ABIERTA": aperturas += 1
            if c["estado"] == "BLOQUEADA" and antes != "BLOQUEADA": bloqueos += 1
            if c["codigo"] != codigo_antes: cambios_codigo += 1
            if antes == "BLOQUEADA" and c["estado"] == "ABIERTA":
                viol["salto de BLOQUEADA a ABIERTA"] = viol.get("salto de BLOQUEADA a ABIERTA", 0) + 1
            for nombre, ok in invariantes(c):
                if not ok: viol[nombre] = viol.get(nombre, 0) + 1
    return visitados, viol, aperturas, bloqueos, cambios_codigo, total

print("Parte 2: verificación con eventos al azar")
for etiqueta, corregido in (("modelo ingenuo (la salida de ENTRADA solo apaga el LED)", False), ("modelo corregido (la salida de ENTRADA también borra los dígitos)", True)):
    visitados, viol, aperturas, bloqueos, cambios, total = verificar(corregido)
    print(f"  {etiqueta}")
    print(f"    estados visitados: " + ", ".join(f"{k}={v}" for k, v in sorted(visitados.items())))
    print(f"    eventos procesados: {total}; aperturas: {aperturas}, bloqueos: {bloqueos}, cambios de código: {cambios}; violaciones: {sum(viol.values())} {viol if viol else ''}")
configurar(True)

# ---- Parte 3: tamaño del modelo y ahorro de la jerarquía ---------------------------------------------------
explicitas = [k for k in H if k[0] in HOJAS]
heredadas = [(e, cl) for e in HOJAS for cl in CLASES if (e, cl) not in H and buscar(e, cl)[1] is not None]
ignoradas = [(e, cl) for e in HOJAS for cl in CLASES if buscar(e, cl)[1] is None]
print()
print("Parte 3: el modelo")
print(f"  estados hoja: {len(HOJAS)}, superestados: 2, clases de evento: {len(CLASES)}, celdas hoja × clase: {len(HOJAS) * len(CLASES)}")
print(f"  celdas con manejador propio: {len(explicitas)}; resueltas por un superestado: {len(heredadas)}; ignoradas: {len(ignoradas)}")
plana = len(H) - 2 + len(heredadas)
print(f"  transiciones escritas: {len(H)} (jerárquica) contra {plana} (plana, repitiendo el comportamiento común)")

Resultado, parte 1: en la apertura normal, al teclear el primer dígito se enciende el LED amarillo (se entra a ENTRADA); al completar el cuarto, la transición CLAVE → ABIERTA apaga el amarillo, emite el pitido de acierto y enciende solenoide y LED verde, en ese orden (salida, acción, entrada); a los 5 s el TIMEOUT los apaga. En el escenario del bloqueo, el tercer error produce amarillo_off, beep_error, rojo_on; los cuatro dígitos del código correcto tecleados durante el bloqueo se ignoran; a los 60 s el contador vuelve a cero y el siguiente intento abre. En el cambio de código, pasar de CLAVE_PROG a PROG_NUEVO y a PROG_CONFIRMAR no apaga el LED amarillo (no se cruza la frontera de ENTRADA); recién al volver a REPOSO aparece amarillo_off; el código viejo deja de abrir y el nuevo abre. Con la puerta forzada, REPOSO pasa a ALARMA_PUERTA por la transición heredada de ASEGURADA. Parte 2, con 391 799 eventos procesados (se visitaron los nueve estados hoja, con 7721 aperturas, 4445 bloqueos y 8 cambios de código): el modelo ingenuo viola 296 372 veces la propiedad «sin dígitos tecleados fuera de la entrada de datos»; el modelo corregido, ninguna, y las otras cuatro propiedades (solenoide, alarma, LED amarillo y LED rojo) se cumplen siempre en ambos. Parte 3: 9 estados hoja y 6 clases de evento son 54 celdas: 16 con manejador propio, 10 resueltas por un superestado y 28 ignoradas; 18 transiciones escritas contra 26 sin jerarquía.

Probá cambiar MAX_FALLOS a 5 o el largo del código a 6, o agregar una propiedad nueva (por ejemplo, «nunca se pasa de BLOQUEADA directamente a ABIERTA», que el programa ya vigila). La respuesta de la verificación a un cambio es inmediata, y es lo que permite modificar el modelo sin miedo.

10 · Exploración interactiva

La cerradura

El laboratorio es la cerradura completa: un teclado, el sensor de puerta, el paso del tiempo y las salidas. El código vigente al empezar es 1234. Mirá cómo el camino de estados muestra la jerarquía (ASEGURADA / ENTRADA / CLAVE), cómo cambian el solenoide, los LED y la alarma, y cómo se registran las transiciones con sus acciones. La opción «Versión del modelo» permite comparar el modelo ingenuo con el corregido.

Teclado, puerta, tiempo y salidas

Estado (camino en la jerarquía)—
Dígitos tecleados—
Errores seguidos—
Solenoide—
LED encendidos—
Alarma—
Plazo pendiente—
Código vigente—

Bitácora (últimos 12 eventos)

—

Usá el teclado y la puerta; avanzá el tiempo con los botones.

Recorrido guiado

  1. Tecleá 1, 2, 3, 4: mientras teclés, el camino es ASEGURADA / ENTRADA / CLAVE y el LED amarillo está encendido. Al 4º dígito: ABIERTA con el solenoide energizado y el LED verde, y el amarillo se apaga. Avanzá +5 s: vuelve a REPOSO y se traba sola (R1).
  2. Tecleá 9, 9, 9, 9, después 1, 1, 1, 1 y 2, 2, 2, 2: el tercer error lleva a BLOQUEADA (LED rojo, plazo de 60 s). Probá 1, 2, 3, 4: se ignora aunque sea el correcto. Avanzá +60 s y el contador de errores vuelve a 0: ahora el 1234 abre (R4 y R5).
  3. Tecleá 1, 2 y avanzá +5 s: se descartó lo tecleado y volvió a REPOSO (R2). Probá también 1, 2, *: cancela al instante, y es la misma transición del superestado ENTRADA para cualquiera de los cuatro estados de ingreso (R3).
  4. Cambio de código: #, 1, 2, 3, 4, 5, 6, 7, 8 y de nuevo 5, 6, 7, 8. Mirá que el LED amarillo no se apaga al pasar de un estado de ingreso a otro. El código vigente pasa a 5678, y el 1234 ya no abre (R6).
  5. Con la cerradura en reposo, apretá «La puerta se abre»: la alarma suena (R7, transición heredada de ASEGURADA). Cerrá la puerta y se silencia. Después abrí con el código, abrí la puerta (el solenoide se apaga) y avanzá +30 s: suena la alarma por puerta abierta (R8).
  6. Cambiá la versión del modelo a Ingenua, tecleá 1, 2 y apretá «La puerta se abre»: la alarma suena y, abajo, aparece el aviso de que los dos dígitos siguen en memoria fuera de la entrada de datos (●● en «Dígitos tecleados»). Con la versión Corregida el buffer queda vacío. Es el defecto que descubrió la verificación con eventos al azar.
Desafío: ¿cómo se agregaría un código de visitante que abre una sola vez?

Un dato de contexto más (codigo_visita y un indicador visita_vigente) y un cambio en la guarda de CLAVE: si el código ingresado coincide con el de visita y el indicador está activo, se abre y se desactiva el indicador, además de aceptarse el código principal como siempre. Los estados no cambian y la jerarquía tampoco; lo que cambia es una guarda y un par de datos. Además, hay que decidir cómo se carga el código de visita (¿un modo de programación más, con otro prefijo?), y cada una de esas decisiones se traduce a requisitos nuevos y a pruebas nuevas. Un buen modelo permite agregar funciones tocando poco, y se nota cuando hay que hacerlo.

11 · Método de trabajo

Método: del cliente al código

  1. Escribí los requisitos con identificadores, en frases cortas y verificables (con números: «5 s», «3 errores»). Una frase que no se puede probar no es un requisito.
  2. Extraé los eventos, los estados y los datos subrayando los requisitos. Los estados se distinguen por cómo reaccionan; lo que varía por cantidad (los dígitos tecleados) es un dato, no un estado.
  3. Buscá la repetición: el mismo evento tratado igual en muchos estados es un superestado.
  4. Dibujá el diagrama con el recorrido principal y completá la tabla de manejadores con todo lo demás. Cada celda ignorada lleva una razón.
  5. Asigná las acciones: condiciones sostenidas en las acciones de entrada y salida (propiedades por construcción); órdenes puntuales en las transiciones. Armá la tabla de temporizadores.
  6. Escribí las propiedades que deben cumplirse siempre (las salidas coherentes con el estado, la ausencia de residuos de datos sensibles), antes de programar.
  7. Implementá con una estructura de contexto, manejadores por (estado, clase), una función única de cambio de estado y una interfaz de hardware.
  8. Probá en tres niveles: por celda, por requisito y con eventos al azar chequeando las propiedades. Cuando una propiedad falla, preguntate si el modelo o los requisitos estaban incompletos.
  9. Sumá las exigencias del dispositivo real (falla de energía, persistencia, tiempo constante, vigilancia, registros) como requisitos y recorrelos por el mismo circuito.
  10. Mantené la trazabilidad: cada requisito, hasta una prueba; cada prueba, hasta un requisito. Cuando el cliente cambia algo, se sabe qué se toca.

12 · Comprensión

Confusiones frecuentes

«Si cumple todos los requisitos, está terminado»

Los requisitos del cliente describen lo que quiere que ocurra; no suelen describir lo que no debe ocurrir ni lo que se pierde en los bordes. Las propiedades y las pruebas al azar encuentran lo que nadie escribió, como los dígitos que sobrevivían en memoria.

«Un estado por cada dígito tecleado»

Multiplica los estados sin necesidad y no escala (un código de 6 dígitos, o de largo configurable, requeriría reescribir la máquina). La cantidad de dígitos es un dato, y la guarda «¿completó?» decide el destino. Los estados son para distinguir comportamientos, no para contar.

«La limpieza de datos sensibles va en cada transición que sale»

Y alguna se olvidará. Una acción de salida del superestado se ejecuta por cualquier camino que abandone el grupo, presente o futuro. Lo que se repite en todas las salidas de un grupo, va en la salida del grupo.

«El contador de errores solo hace falta mientras el equipo está encendido»

Para quien ataca, un reinicio es una forma de reiniciar el contador. Un límite de intentos que se pierde al cortar la alimentación es un límite que se puede evadir. Los datos de seguridad que deben sobrevivir a un reinicio se guardan en memoria no volátil.

«Comparar el código con memcmp es suficiente»

memcmp puede terminar en cuanto encuentra una diferencia, y el tiempo que tarda filtra cuántos dígitos iniciales coinciden. En un teclado de cuatro dígitos es una amenaza menor, pero en sistemas con claves más largas es un ataque real. La comparación en tiempo constante cuesta tres líneas.

«Probar con eventos al azar reemplaza a los casos pensados»

Los complementa: el azar encuentra las combinaciones que nadie pensó, pero no sabe qué es correcto; las propiedades se lo dicen. Y los escenarios de requisito documentan y protegen lo que se pidió. Cada nivel de prueba encuentra defectos distintos.

«Un modelo más grande y detallado es un modelo mejor»

El modelo mínimo que distingue los comportamientos y expresa las propiedades es el mejor. Con 9 estados hoja y 2 superestados, esta cerradura cubre todos los requisitos; cada estado extra sería una celda más para decidir, probar y mantener.

13 · Práctica guiada

Ejercicios y variantes

1. El lavarropas

Modelá el ciclo de un lavarropas con los estados INACTIVO, LLENANDO, LAVANDO, VACIANDO, ENJUAGANDO y CENTRIFUGANDO. Los eventos PAUSA, PUERTA_ABIERTA y EMERGENCIA deben detener el motor y cerrar la entrada de agua en cualquiera de los seis estados del ciclo. ¿Cuántas transiciones hay sin jerarquía y con jerarquía para esos tres eventos? ¿Qué acciones van en las salidas del superestado?

Ver solución

Sin jerarquía: 6 estados × 3 eventos = 18 transiciones, todas con las mismas acciones (motor parado, agua cerrada). Con un superestado EN_CICLO que contiene los seis: 3 transiciones (una por evento), hacia un estado EN_PAUSA o FALLO según el evento. Las acciones de salida de EN_CICLO son motor_parar() y valvula_agua(false): se ejecutan siempre que se abandona el ciclo, por cualquier camino, y no hay que repetirlas. Las acciones de entrada de cada fase (abrir la válvula en LLENANDO, girar el motor en LAVANDO, etc.) quedan en cada estado; y la reanudación vuelve al estado que se estaba ejecutando mediante un estado de historia, o simplemente reinicia la fase en curso (una decisión de producto).

2. El contador de errores y el reinicio

El cliente pide que el contador de errores sobreviva a un corte de energía. ¿Qué agregás al modelo, a la implementación y a las pruebas?

Ver solución

Modelo: el contador es un dato de contexto que se persiste. La acción «guardar contador» se ejecuta cada vez que cambia (error y acierto), y el arranque lo restaura antes de entrar al estado inicial; si el valor restaurado es ≥ 3, el estado inicial es BLOQUEADA y no REPOSO. Implementación: una función de lectura y otra de escritura en memoria no volátil con integridad (suma de verificación), y atención al desgaste: no escribir si el valor no cambió. Pruebas: un caso que reinicia la máquina en medio del conteo (dos errores, reinicio, un error) y verifica que se bloquee en el tercero; y otro con el dato corrupto, que debe llevar a un estado seguro y no al valor aleatorio.

3. Código de visitante

Se agrega un código de visitante que abre una sola vez. ¿Qué estados, eventos y datos cambian?

Ver solución

Ningún estado ni evento nuevos. Un dato (codigo_visita) y un indicador (visita_vigente). La guarda del cuarto dígito en CLAVE pasa a «coincide con el código principal o con el de visita vigente»; si fue el de visita, se apaga el indicador. Se agregan requisitos sobre cómo se carga y cuándo vence, y propiedades nuevas («un código de visita usado nunca vuelve a abrir»). La mayor parte del trabajo es de decisión de producto, no de estados.

4. Una propiedad nueva

Escribí una propiedad que garantice que «la alarma sonora solo se detiene por el cierre de la puerta». ¿Cómo la vigilarías con eventos al azar?

Ver solución

Propiedad: «si el estado anterior era ALARMA_PUERTA y el nuevo es distinto, el evento procesado fue P_CIERRA». En el generador de eventos al azar, después de cada evento se compara el par (estado anterior, estado nuevo) con el evento que lo causó; cualquier violación (por ejemplo, que un TIMEOUT o una tecla silenciaran la alarma) se cuenta. En el modelo actual es cierta, porque ALARMA_PUERTA solo tiene la transición de P_CIERRA y el superestado ASEGURADA no lo contiene; si se la moviera por error dentro de ASEGURADA, la verificación lo señalaría.

5. Cuánto cuesta la fuerza bruta

Con 4 dígitos (10 000 códigos), 3 errores seguidos y 60 s de bloqueo, ¿cuánto tarda un atacante en recorrer todos los códigos en el peor caso, tecleando 3 intentos por minuto? ¿Y si el bloqueo se duplica en cada reincidencia (60 s, 120 s, 240 s...)?

Ver solución

Con bloqueo fijo: cada ciclo de 3 intentos más 60 s de espera es del orden de un minuto, así que unos 3 intentos por minuto → 10 000 / 3 ≈ 3333 minutos, unas 56 horas (en promedio, la mitad: 28 horas). Con el bloqueo que se duplica en cada reincidencia, el tiempo se dispara: en el 10.º bloqueo ya es de más de 8 horas, y recorrer el espacio completo lleva años. Por eso el contador de bloqueos reincidentes es una defensa barata y eficaz, a condición de persistirlo (ejercicio 2).

6. Trazabilidad

Armá la trazabilidad de R2: ¿qué prueba por celda, qué escenario y qué propiedad lo cubren?

Ver solución

Celdas: (CLAVE, TIMEOUT) y (CLAVE_PROG, TIMEOUT) → REPOSO con buffer vacío; y las celdas de dígito intermedio, que verifican que el plazo se reinicia. Escenario: «teclear 1, 2, avanzar 6 s, teclear 3, 4» no debe abrir ni dejar nada a medias. Propiedad: «fuera de ENTRADA no hay dígitos en memoria» (R10) se cumple tras el TIMEOUT. Si el requisito R2 cambiara a 8 s, se sabe que hay que tocar la tabla de temporizadores y estas tres pruebas, y nada más.

14 · Síntesis del bloque

Ideas para recordar

  • Se parte de los requisitos con identificadores, escritos como frases verificables. De ellos se extraen eventos, estados y datos; lo que varía por cantidad es un dato y no un estado.
  • La repetición de un mismo comportamiento en varios estados señala un superestado: ENTRADA y ASEGURADA reducen el modelo de 26 transiciones a 18 sin cambiar el comportamiento.
  • Las salidas físicas son propiedades por construcción si se ponen en las acciones de entrada y de salida: el solenoide está energizado si y solo si el estado es ABIERTA; el LED amarillo, si y solo si se está dentro de ENTRADA.
  • La implementación usa una estructura de contexto, manejadores por (estado, clase), búsqueda hacia arriba por los superestados y una única función de cambio de estado; la comparación del código es en tiempo constante.
  • Se prueba en tres niveles: una prueba por celda, un escenario por requisito y eventos al azar con propiedades verificadas tras cada evento. Esta última encontró el defecto de los dígitos residuales, que no estaba en ningún requisito.
  • La corrección fue una línea en la acción de salida del superestado: lo que debe ocurrir al abandonar un grupo, por cualquier camino, se escribe una sola vez en la frontera.
  • Un dispositivo real agrega exigencias que no son lógica: falla de energía, persistencia de datos de seguridad, reinicios, vigilancia, registros sin datos sensibles. Se tratan como requisitos más y recorren el mismo circuito hasta la prueba.
  • El bloque entero se resume así: modelar antes de programar (tema 24), implementar de modo que el código se parezca al modelo y se pueda probar (25), organizar el sistema en torno a eventos y jerarquías (26), y aplicarlo a un producto completo (27).

¿Listo para el bloque siguiente?

Antes de avanzar, tomá un dispositivo que tengas a mano (una cafetera, un cargador, un portero eléctrico) y recorré el circuito completo: diez requisitos con identificadores, los eventos y estados que salen de ellos, un diagrama con al menos un superestado, la tabla de manejadores con cada celda justificada, tres propiedades que deben cumplirse siempre y las pruebas que las comprueban. Si cada paso deja algo que se puede revisar, tenés un método, y no una intuición.

En el próximo bloque pasamos de la lógica a los recursos: la memoria. Empezamos por los tipos de memoria de un microcontrolador —Flash, SRAM, EEPROM y registros— qué guarda cada una, cuánto cuesta y cuánto dura.