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:
| Id | Requisito |
|---|---|
| R1 | Con 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. |
| R2 | Cada dígito reinicia un plazo de 5 s: si vence antes de completar el código, se descarta lo tecleado. |
| R3 | La tecla * cancela la entrada en curso. |
| R4 | Un 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. |
| R5 | Un acierto pone en cero el contador de errores; el fin del bloqueo también. |
| R6 | Con # 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. |
| R7 | Si la puerta se abre sin autorización (con la cerradura asegurada), suena la alarma hasta que se cierra. |
| R8 | Si la puerta se abre tras una autorización y queda abierta más de 30 s, suena la alarma hasta que se cierra. |
| R9 | El 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:
| Categoría | Elementos | De dónde sale |
|---|---|---|
| Eventos de entrada | DIGITO(d), ASTERISCO, NUMERAL, P_ABRE, P_CIERRA | Teclado y sensor de puerta (R1–R3, R6–R8) |
| Eventos de tiempo | TIMEOUT (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 reaccionan | REPOSO, CLAVE (ingresando para abrir), CLAVE_PROG (ingresando para programar), PROG_NUEVO, PROG_CONFIRMAR, ABIERTA, PUERTA_ABIERTA, ALARMA_PUERTA, BLOQUEADA | Cada uno responde distinto a los mismos dígitos o a la puerta |
| Datos de contexto | El 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 estadoCLAVEcon 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 (
ABIERTAoPROG_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).
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:
| En | Evento | Guarda | Destino | Acción |
|---|---|---|---|---|
| REPOSO | DIGITO | — | CLAVE | buffer = [d] |
| REPOSO | NUMERAL | — | CLAVE_PROG | buffer = [] |
| CLAVE y CLAVE_PROG | DIGITO | buffer incompleto | (interna) | agregar; rearmar 5 s |
| CLAVE | DIGITO | 4º dígito, correcto | ABIERTA | fallos = 0; pitido de acierto |
| CLAVE_PROG | DIGITO | 4º dígito, correcto | PROG_NUEVO | fallos = 0; pitido de acierto |
| CLAVE y CLAVE_PROG | DIGITO | 4º dígito, incorrecto | REPOSO (o BLOQUEADA si fallos = 3) | fallos++; pitido de error |
| CLAVE, CLAVE_PROG, PROG_NUEVO, PROG_CONFIRMAR | TIMEOUT | — | REPOSO | descartar lo tecleado |
| PROG_NUEVO | DIGITO | buffer incompleto | (interna) | agregar; rearmar 10 s |
| PROG_NUEVO | DIGITO | 4º dígito | PROG_CONFIRMAR | guardar como código nuevo |
| PROG_CONFIRMAR | DIGITO | buffer incompleto | (interna) | agregar; rearmar 10 s |
| PROG_CONFIRMAR | DIGITO | 4º dígito | REPOSO | si coincide, código = nuevo; pitido de acierto o de error |
| ABIERTA | TIMEOUT | 5 s | REPOSO | — |
| ABIERTA | P_ABRE | — | PUERTA_ABIERTA | — |
| PUERTA_ABIERTA | P_CIERRA | — | REPOSO | — |
| PUERTA_ABIERTA | TIMEOUT | 30 s | ALARMA_PUERTA | — |
| ALARMA_PUERTA | P_CIERRA | — | REPOSO | — |
| BLOQUEADA | TIMEOUT | 60 s | REPOSO | fallos = 0 |
| ENTRADA (superestado) | ASTERISCO | — | REPOSO | descartar 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:
| Estado | Al entrar | Al salir | Plazo (evento TIMEOUT) |
|---|---|---|---|
| ENTRADA (superestado) | LED amarillo encendido | LED amarillo apagado | — |
| CLAVE, CLAVE_PROG | — | — | 5 s, reiniciado en cada dígito |
| PROG_NUEVO, PROG_CONFIRMAR | — | — | 10 s, reiniciado en cada dígito |
| ABIERTA | Solenoide energizado; LED verde encendido | Solenoide apagado; LED verde apagado | 5 s |
| PUERTA_ABIERTA | — | — | 30 s |
| ALARMA_PUERTA | Alarma sonora | Silencio | — |
| BLOQUEADA | LED rojo encendido | LED rojo apagado | 60 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:
- 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.
- 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.
- 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:
| Tema | Pregunta | Decisió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
| Req. | Prueba | Resultado esperado |
|---|---|---|
| R1 | Teclear 1-2-3-4 y medir con el analizador el pin del solenoide | Energizado durante 5 s ±1 tick y luego apagado; LED verde en sincronía |
| R2 | Teclear dos dígitos y esperar 6 s; teclear dos más | Los primeros se descartaron: los dos siguientes no completan nada |
| R3 | Teclear tres dígitos, *, y luego los cuatro del código | Abre (no fue necesario terminar el primer intento) |
| R4, R5 | Tres códigos errados seguidos; luego el correcto; después de 60 s, el correcto | Tres 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 viejo | El nuevo abre y el viejo no; y con una confirmación que no coincide, el viejo sigue valiendo |
| R7 | Abrir la puerta con la cerradura asegurada | Alarma inmediata, que se detiene al cerrar |
| R8 | Autorizar, abrir y dejar abierto 35 s | El solenoide se apaga al abrir; a los 30 s suena la alarma hasta cerrar |
| R9 | Observar el solenoide durante todas las pruebas anteriores | Energizado solo en el tramo de apertura autorizada |
| R10 | Con un depurador, forzar la puerta con dos dígitos tecleados y leer el buffer | El buffer está en cero |
| Reinicio | Cortar la alimentación en medio del bloqueo y volver a conectar | Arranca asegurada, con el solenoide apagado, sin destello; el contador restaurado si se implementó |
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| El primer código tras un bloqueo siempre falla | El contador de errores no se puso en cero al terminar el bloqueo | La acción del TIMEOUT de BLOQUEADA debe hacer fallos = 0 |
| Tecleando rápido se cuentan dígitos de más | Rebote del teclado convertido en eventos | Antirrebote y flanco antes de la cola de eventos |
| El solenoide queda energizado después de abrir la puerta | Falta la acción de salida de ABIERTA, o hay una transición que se saltea la salida | Todo cambio de estado por la función única ir_a |
| A veces abre con un código a medias | El buffer conserva dígitos de un intento anterior | Limpieza en la acción de salida de ENTRADA |
| La alarma no se detiene al cerrar | El evento P_CIERRA se genera por nivel o con rebote | Evento por flanco, con filtro, desde el sensor |
| Un reinicio durante el bloqueo permite intentar de nuevo | El contador de errores solo está en RAM | Persistirlo 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
Bitácora (últimos 12 eventos)
—
Usá el teclado y la puerta; avanzá el tiempo con los botones.
Recorrido guiado
- Tecleá 1, 2, 3, 4: mientras teclés, el camino es
ASEGURADA / ENTRADA / CLAVEy el LED amarillo está encendido. Al 4º dígito:ABIERTAcon el solenoide energizado y el LED verde, y el amarillo se apaga. Avanzá +5 s: vuelve aREPOSOy se traba sola (R1). - 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). - 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 superestadoENTRADApara cualquiera de los cuatro estados de ingreso (R3). - 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).
- 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). - 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
- 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.
- 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.
- Buscá la repetición: el mismo evento tratado igual en muchos estados es un superestado.
- 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.
- 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.
- Escribí las propiedades que deben cumplirse siempre (las salidas coherentes con el estado, la ausencia de residuos de datos sensibles), antes de programar.
- Implementá con una estructura de contexto, manejadores por (estado, clase), una función única de cambio de estado y una interfaz de hardware.
- 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.
- Sumá las exigencias del dispositivo real (falla de energía, persistencia, tiempo constante, vigilancia, registros) como requisitos y recorrelos por el mismo circuito.
- 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:
ENTRADAyASEGURADAreducen 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 deENTRADA. - 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.