01 · Punto de partida
Por qué abstraer
Un termostato lee una temperatura y enciende o apaga un calefactor. Escrito de golpe, el programa empieza con analogRead(A0), aplica una fórmula de termistor, compara con una consigna y escribe digitalWrite(5, HIGH). Funciona. El problema llega después: el NTC se reemplaza por un DS18B20 (temas 9 y 12), el calefactor pasa de un relé a un control proporcional, o el mismo termostato debe usarse en otro proyecto. Si la lógica, la fórmula y los pines están mezclados, cada cambio obliga a tocar todo y a volver a probarlo todo.
Un driver encapsula lo que depende del dispositivo (registros, conversiones, tiempos, pines) y una capa de abstracción expone solo lo que la aplicación necesita saber («la temperatura, en °C, y si es fiable»). Los beneficios, de más a menos concretos:
- Intercambiabilidad: con S sensores y A aplicaciones, sin interfaz hay que adaptar cada combinación (S × A); con interfaz, un driver por sensor y una aplicación por función (S + A). Con 5 sensores y 5 aplicaciones son 25 piezas frente a 10.
- Pruebas: la lógica se prueba con un sensor simulado, sin placa, sin calentar nada, y con las fallas que se quieran.
- Legibilidad: la aplicación dice qué hace («si la temperatura es menor que la consigna, calentar») y los drivers dicen cómo se obtiene o se ejecuta.
- Seguridad: las protecciones (límites, estado seguro, tiempos mínimos) viven en un solo lugar y valen para todas las aplicaciones (tema 44).
Pero también hay un costo: más archivos, más indirección y la tentación de abstraer demasiado pronto. La regla práctica es la «regla de tres»: la primera vez se escribe directo; la segunda, se duplica con dolor; a la tercera, o en cuanto aparece un segundo dispositivo, se extrae la interfaz.
02 · Estructura
Las capas de un sistema embebido
| Capa | Qué sabe | Ejemplo |
|---|---|---|
| Aplicación | Las reglas del problema, en unidades físicas | Consigna 24 °C, histéresis ± 0,5 °C, alarma si no hay dato |
| Interfaz (abstracción) | Un contrato: operaciones, unidades, estados de error | SensorTemperatura, Salida |
| Driver | Un dispositivo concreto: registros, conversiones, tiempos | Ds18b20 (750 ms de conversión), NtcAdc (β = 3950) |
| Plataforma (HAL) | El microcontrolador: pines, buses, tiempo | analogRead, Wire, millis; distinto en un AVR y en un ESP32 |
| Hardware | Cables, tensiones, el componente físico | Un divisor de tensión, un bus OneWire |
La regla de oro es que las dependencias apuntan hacia abajo y pasan por interfaces. La aplicación conoce el contrato SensorTemperatura, nunca Ds18b20; el driver conoce la plataforma (OneWire), nunca la aplicación. De la regla se deducen las prohibiciones útiles: la aplicación no llama a analogRead, no menciona números de pin y no ve cuentas del ADC; un driver no sabe qué hará la aplicación con la temperatura, no imprime por el puerto serie y no decide si calentar.
Dónde se enchufa cada driver en la aplicación es una decisión única, tomada en un solo lugar (la «raíz de composición», normalmente setup()): ahí, y solo ahí, aparecen los nombres de las clases concretas. Es la forma más simple de inyección de dependencias: el termostato recibe, por el constructor, el sensor y el calefactor que debe usar.
03 · El contrato
Qué debe tener la interfaz de un driver
Una buena interfaz de sensor o de actuador cumple unas pocas reglas, que casi todas salen de cosas que ya aprendimos a las malas:
- Unidades físicas en el contrato:
°C,mm,%, no cuentas de ADC. La conversión es asunto del driver. En microcontroladores sin unidad de coma flotante, se pueden devolver enteros en décimas de grado o en milímetros. - Estado explícito, no valores centinela: una función que devuelve −127 o 85 cuando falla obliga a todos los llamadores a conocer la convención. El DS18B20 devuelve 85,0 °C al encenderse (si la conversión no ocurrió) y la biblioteca más usada devuelve −127 si no hay sensor: son temperaturas posibles de una habitación de horno y de un congelador industrial, y la aplicación las tomaría por buenas. El contrato devuelve una
Lecturacon valor, estado y marca de tiempo, y estados comoOK,SIN_RESPUESTA,FUERA_DE_RANGOyDATO_VIEJO. - Sin bloqueos: las operaciones lentas se parten en
iniciar(),lista()yleer(). Un DS18B20 de 12 bits tarda 750 ms en convertir; una lectura bloqueante detiene un lazo de 10 ms durante 75 ciclos (tema 41). - Sin efectos laterales: el driver no imprime, no llama a
delay()y no usa variables globales: la configuración (pin, dirección I2C) entra por el constructor, de modo que se pueden tener varias instancias. - Inicialización que informa:
begin()devuelve si el dispositivo respondió; una aplicación que arranca sin sensor debe saberlo. - Documentada: en el encabezado, las unidades, el rango, el tiempo de conversión, los estados que puede devolver y si es seguro llamarla desde una interrupción.
| Dispositivo | Tiempo para un dato |
|---|---|
| NTC con ADC | ~ 0,1 ms |
| DS18B20, 9 bits | 93,75 ms |
| DS18B20, 12 bits | 750 ms |
| DHT22 (período mínimo entre lecturas) | 2 s |
Y un detalle que ahorra tiempo: con varios DS18B20 en un mismo bus, una sola orden requestTemperatures() los hace convertir a la vez; leerlos uno tras otro con espera costaría 4 × 750 ms = 3 s, y en paralelo, 0,75 s.
04 · C++
Patrones en C++
Arduino es C++, y el lenguaje ofrece varias formas de escribir una interfaz. Se elige según cuándo se decide qué dispositivo se usa:
| Técnica | Cómo | Cuándo conviene | Costo |
|---|---|---|---|
Clase abstracta (virtual) | Interfaz con métodos virtuales puros; los drivers heredan | El dispositivo puede cambiar en tiempo de ejecución o se elige por configuración | Un puntero por objeto y una tabla por clase; una llamada indirecta (despreciable frente a una lectura de ms) |
Plantillas (template) | La aplicación se escribe para cualquier tipo con ciertos métodos | El dispositivo se fija al compilar | Sin costo en ejecución; un código por tipo; errores de compilación largos |
| Punteros a función (C) | Una estructura con los punteros a las operaciones | Proyectos en C puro | Menos seguro y más verboso |
Una variante muy útil es el decorador: una clase que implementa la misma interfaz y envuelve a otra, para agregar un comportamiento sin que nadie lo note. Por ejemplo, Filtrado aplica un promedio móvil (tema 5), Calibrado suma un desvío y una ganancia (tema 5) y Validado rechaza lecturas fuera de rango (tema 44). Se apilan: Calibrado(Filtrado(Ds18b20)) sigue siendo un SensorTemperatura, y la aplicación ni se entera. Es la forma limpia de que filtros y calibraciones no queden esparcidos por todo el programa.
Tres consejos específicos de microcontroladores: crear los objetos de forma estática (variables globales o locales de setup), sin new ni malloc que fragmentan una RAM de 2 KB; no usar std::function ni excepciones (no están disponibles en un AVR); y evitar herencia profunda: una interfaz y sus implementaciones, nada más.
05 · Salidas
Interfaces para actuadores
Con los actuadores pasa lo mismo, a la inversa. La aplicación debería pedir qué quiere lograr («potencia del 60 %») y no cómo («poner el pin 5 en alto durante 6 s»). Una interfaz mínima:
establecer(potencia): valor de 0 a 1 (o 0 a 100 %), en unidades que la aplicación entiende.apagar(): estado seguro, que siempre funciona y es lo primero que llama la aplicación ante una falla.actualizar(): para los drivers con tiempos propios, que se llama en cada vuelta del lazo, sin bloquear.
Tras esa interfaz caben implementaciones muy distintas: un PWM (con la resolución de su timer y, en una luz, con corrección gamma, tema 35), un servo (el ángulo se convierte en un pulso de 1000 a 2000 µs, tema 32) o un relé proporcional en el tiempo, que no puede conmutar rápido y reparte el 60 % dentro de una ventana: 6 s encendido y 4 s apagado en una ventana de 10 s. La ventana importa: un relé admite unos 100 000 ciclos eléctricos (valor típico a plena carga; depende del modelo), y la ventana decide cuánto dura:
| Ventana | Ciclos por hora | Horas hasta 100 000 ciclos | Años |
|---|---|---|---|
| 10 s | 360 | 278 | 0,03 |
| 60 s | 60 | 1667 | 0,19 |
| 600 s | 6 | 16 667 | 1,90 |
La interfaz es la que permite poner esa protección donde corresponde: el driver del relé fija una ventana larga y un tiempo mínimo de encendido y apagado, y la aplicación del termostato no necesita saberlo. Otras responsabilidades del driver de una salida:
- Lógica invertida oculta: los módulos de relé suelen activarse con nivel bajo. El driver lo resuelve, y para la aplicación 1 siempre es «encendido».
- Estado seguro al arrancar: escribir el nivel de reposo antes de poner el pin como salida (
digitalWrite(pin, nivelApagado); pinMode(pin, OUTPUT);), para que no haya un pulso al encender. - Límites: potencia máxima, velocidad máxima de cambio, tiempo mínimo entre conmutaciones (un compresor no debe arrancar de nuevo antes de unos minutos).
- Apagado ante falla:
apagar()se llama si el sensor falla, si el programa se cuelga (watchdog) o si la comunicación se corta (tema 44).
06 · Datos
Configuración y calibración
Los números que cambian con el montaje no pertenecen a la lógica: pines, direcciones I2C, consigna, histéresis, períodos, constantes del sensor (β de un NTC, desvíos de calibración). Se reúnen en un único archivo, con nombres que llevan la unidad (CONSIGNA_C, PERIODO_MS) y tipos constexpr, en vez de números mágicos dispersos. Si el mismo programa corre en dos placas (un Uno y un ESP32), el mapa de pines se separa con la macro de la arquitectura, en un solo sitio.
La calibración es un caso particular: son datos que se miden en cada unidad (un desvío de 0,3 °C, una ganancia de 1,02). No se compilan en el programa: se guardan en la EEPROM o en la memoria flash, con una suma de verificación (CRC) para detectar un dato corrupto, y con valores por defecto para cuando no hay calibración. El driver la lee al iniciar, y la aplicación nunca se entera. Los procedimientos de calibración (tema 5) producen esos datos; el driver los aplica.
07 · Verificación
Pruebas sin hardware
Probar un termostato calentando una habitación lleva horas y no permite repetir una falla. Con la lógica separada del hardware, se prueba en segundos:
- Driver simulado (fake): implementa la interfaz con una lista de valores. Alimenta la aplicación con una serie de temperaturas (22; 23,2; 24,2; 24,6; 24,4…) y se comprueba que la histéresis hace lo esperado, y con una falla (sin respuesta) que la aplicación pasa a estado seguro.
- Funciones puras de conversión: separar
convertir(cuentas) → °Cde la lectura del pin permite probarla con valores conocidos: 512 cuentas en un divisor de 10 kΩ con un NTC de 10 kΩ son 25 °C (24,96 °C con la fórmula β); el registro0x0191del DS18B20 son 25,0625 °C y0xFF5E, −10,125 °C. - Pruebas nativas: con PlatformIO (entorno
native) o con un compilador del PC, la aplicación y los drivers simulados se compilan y se ejecutan en el ordenador, con un marco de pruebas como Unity o doctest, y se integran en la integración continua. - Casos límite: sensor desconectado, valor de arranque (85 °C), lectura fuera de rango, dato viejo, desbordamiento de
millis(). - La prueba de la interfaz: la misma aplicación, con distintos drivers y la misma temperatura real de entrada, debe tomar las mismas decisiones. Si no lo hace, la abstracción tiene una filtración.
Las pruebas con hardware real siguen haciendo falta, pero se reducen a verificar cada driver contra su dispositivo (¿mide lo que debe?) y no a probar toda la lógica en cada cambio.
08 · Orden
Organización y antipatrones
Una estructura de carpetas que refleja las capas ayuda a que se respeten:
src/
main.cpp raíz de composición: crea los objetos y los conecta
config.h pines, consignas, constantes del montaje
app/
Termostato.h/.cpp lógica; solo conoce interfaces
interfaces/
SensorTemperatura.h contrato del sensor (Lectura, Estado)
Salida.h contrato del actuador
drivers/
NtcAdc.h/.cpp
Ds18b20.h/.cpp
SalidaRele.h/.cpp
SensorSimulado.h para pruebas
decoradores/
Filtrado.h Calibrado.h Validado.h
test/
test_termostato.cpp pruebas nativas con el sensor simulado
Una clase por archivo, con un comentario de encabezado que documenta el contrato. Y los antipatrones más comunes:
| Antipatrón | Por qué duele | Alternativa |
|---|---|---|
loop() gigante que lo mezcla todo | No se puede probar ni reutilizar nada | Capas, y un loop() que solo llama a actualizar() |
| Copiar y pegar la lectura de un sensor en cada proyecto | Un error corregido en uno sigue en los demás | Un driver compartido |
| Pines y constantes sueltos en el código | Cambiar de placa obliga a buscarlos | config.h |
delay() o Serial.print dentro de un driver | Bloquea o ensucia a todos los que lo usan | Estados y tiempos con millis(); mensajes en la aplicación |
| Cuentas del ADC que llegan a la aplicación | La lógica depende del divisor y de la resolución | El driver devuelve unidades físicas |
| Valores centinela (−127, −1, 85) | Se toman por temperaturas verdaderas | Estado explícito en la lectura |
| Interfaces enormes, creadas antes de tiempo | Cuesta implementarlas y no encajan en el segundo dispositivo | La interfaz mínima; regla de tres |
| La biblioteca de un tercero usada en toda la aplicación | Cambiarla obliga a modificar todo | Un driver que la envuelve (adaptador) |
09 · Programación
Un ejemplo completo
El termostato con su interfaz, dos drivers de sensor (uno con lectura por etapas), un driver de salida con relé proporcional, un decorador y la raíz de composición. La lógica de Termostato no menciona un solo pin ni una sola biblioteca.
// ---------- config.h ----------
namespace Config {
constexpr uint8_t PIN_ONEWIRE = 4, PIN_CALEFACTOR = 5, PIN_NTC = A0;
constexpr float CONSIGNA_C = 24.0f, HISTERESIS_C = 0.5f;
constexpr uint32_t PERIODO_MS = 2000, VENTANA_RELE_MS = 600000UL; // ventana de 10 min: ~6 ciclos por hora
}
// ---------- interfaces ----------
enum class Estado : uint8_t { OK, SIN_RESPUESTA, FUERA_DE_RANGO, DATO_VIEJO };
struct Lectura { float valor; Estado estado; uint32_t tMs; };
class SensorTemperatura { // contrato: °C, estado explícito, sin bloqueos
public:
virtual ~SensorTemperatura() {}
virtual bool begin() = 0; // true si el dispositivo respondió
virtual void iniciar() = 0; // pide una medición y vuelve
virtual bool lista() = 0; // ¿terminó la conversión?
virtual Lectura leer() = 0;
};
class Salida { // contrato del actuador: 0.0 a 1.0
public:
virtual ~Salida() {}
virtual void establecer(float potencia) = 0;
virtual void apagar() = 0; // estado seguro
virtual void actualizar() {} // para drivers con tiempos propios
};
// ---------- drivers ----------
class Ds18b20 : public SensorTemperatura {
public:
Ds18b20(DallasTemperature &bus, uint8_t indice) : bus_(bus), indice_(indice) {}
bool begin() override { bus_.begin(); bus_.setWaitForConversion(false); return bus_.getDeviceCount() > indice_; }
void iniciar() override { bus_.requestTemperatures(); inicio_ = millis(); }
bool lista() override { return millis() - inicio_ >= 750; } // 12 bits: 750 ms
Lectura leer() override {
float t = bus_.getTempCByIndex(indice_);
Estado e = Estado::OK;
if (t == DEVICE_DISCONNECTED_C) e = Estado::SIN_RESPUESTA; // -127
else if (t == 85.0f) e = Estado::FUERA_DE_RANGO; // valor de arranque: no hubo conversión
return {t, e, millis()};
}
private:
DallasTemperature &bus_; uint8_t indice_; uint32_t inicio_ = 0;
};
class NtcAdc : public SensorTemperatura {
public:
NtcAdc(uint8_t pin, float beta) : pin_(pin), beta_(beta) {}
bool begin() override { return true; }
void iniciar() override {}
bool lista() override { return true; } // una lectura de ADC tarda ~0,1 ms
Lectura leer() override {
uint16_t c = analogRead(pin_);
if (c == 0 || c >= 1023) return {NAN, Estado::FUERA_DE_RANGO, millis()}; // cable cortado o en corto
float r = 10000.0f * c / (1023.0f - c);
float tK = 1.0f / (1.0f / 298.15f + log(r / 10000.0f) / beta_);
return {tK - 273.15f, Estado::OK, millis()};
}
private:
uint8_t pin_; float beta_;
};
class SalidaRele : public Salida { // relé proporcional en el tiempo: ventana larga, poco desgaste
public:
SalidaRele(uint8_t pin, uint32_t ventanaMs) : pin_(pin), ventana_(ventanaMs) {
digitalWrite(pin_, LOW); pinMode(pin_, OUTPUT); // nivel de reposo antes de ser salida: sin pulso al arrancar
}
void establecer(float p) override { potencia_ = constrain(p, 0.0f, 1.0f); }
void apagar() override { potencia_ = 0; digitalWrite(pin_, LOW); }
void actualizar() override { digitalWrite(pin_, (millis() % ventana_) < potencia_ * ventana_); }
private:
uint8_t pin_; uint32_t ventana_; float potencia_ = 0;
};
// ---------- decorador: un promedio móvil que sigue siendo un SensorTemperatura ----------
class Filtrado : public SensorTemperatura {
public:
Filtrado(SensorTemperatura &s, float alfa) : s_(s), alfa_(alfa) {}
bool begin() override { return s_.begin(); }
void iniciar() override { s_.iniciar(); }
bool lista() override { return s_.lista(); }
Lectura leer() override {
Lectura l = s_.leer();
if (l.estado != Estado::OK) return l;
y_ = primera_ ? l.valor : y_ + alfa_ * (l.valor - y_); // filtro exponencial
primera_ = false;
l.valor = y_;
return l;
}
private:
SensorTemperatura &s_; float alfa_, y_ = 0; bool primera_ = true;
};
// ---------- aplicación: solo conoce las interfaces ----------
class Termostato {
public:
Termostato(SensorTemperatura &s, Salida &calefactor) : s_(s), c_(calefactor) {}
void actualizar() {
if (!midiendo_ && millis() - ultimo_ >= Config::PERIODO_MS) { s_.iniciar(); midiendo_ = true; }
else if (midiendo_ && s_.lista()) {
midiendo_ = false; ultimo_ = millis();
Lectura l = s_.leer();
if (l.estado != Estado::OK) { c_.apagar(); return; } // sin dato fiable: estado seguro
if (l.valor < Config::CONSIGNA_C - Config::HISTERESIS_C) c_.establecer(1.0f);
else if (l.valor > Config::CONSIGNA_C + Config::HISTERESIS_C) c_.establecer(0.0f);
}
c_.actualizar();
}
private:
SensorTemperatura &s_; Salida &c_; bool midiendo_ = false; uint32_t ultimo_ = 0;
};
// ---------- raíz de composición: el único lugar que conoce los tipos concretos ----------
OneWire oneWire(Config::PIN_ONEWIRE);
DallasTemperature dallas(&oneWire);
Ds18b20 sensorCrudo(dallas, 0); // para un NTC: NtcAdc sensorCrudo(Config::PIN_NTC, 3950);
Filtrado sensor(sensorCrudo, 0.3f);
SalidaRele calefactor(Config::PIN_CALEFACTOR, Config::VENTANA_RELE_MS);
Termostato termostato(sensor, calefactor);
void setup() { sensor.begin(); }
void loop() { termostato.actualizar(); }
Cambiar el DS18B20 por un NTC es una línea de la raíz de composición. Probar el termostato en el PC es reemplazar esa línea por un SensorSimulado y el relé por una salida que anota lo que se le pide. La aplicación no cambia.
10 · Diseño
Errores de diseño y verificación
| Síntoma | Causa | Defensa |
|---|---|---|
| Cambiar un sensor obliga a editar la aplicación | La aplicación conoce el driver o las cuentas | Interfaz con unidades físicas; inyección por constructor |
| El lazo se detiene al leer | La interfaz solo tiene un leer() bloqueante | iniciar(), lista(), leer() |
| La calefacción sigue encendida con el sensor desconectado | Un valor centinela interpretado como temperatura | Estado en la lectura; la aplicación apaga ante cualquier estado distinto de OK |
| La temperatura salta a 85 °C al arrancar | Valor de reset del DS18B20 tomado por bueno | El driver lo marca FUERA_DE_RANGO |
| Dos instancias del mismo sensor se pisan | Variables globales dentro del driver | Estado en el objeto; configuración por constructor |
| El relé se desgasta en semanas | Ventana corta en el control proporcional | Ventana de minutos y tiempo mínimo en el driver |
| Un pulso en la salida al encender | pinMode antes que digitalWrite | Escribir el reposo primero |
| Imposible probar sin la placa | La lógica llama directamente a funciones de la plataforma | Drivers simulados y funciones puras de conversión |
- Cambiá el driver por uno simulado y comprobá que la aplicación compila y funciona sin tocarla.
- Buscá en la aplicación cualquier llamada a
analogRead,digitalWrite,Wireo números de pin: no debería haber ninguna. - Inyectá fallas (sin respuesta, fuera de rango, dato viejo) y verificá que la salida pasa a estado seguro.
- Medí el tiempo de
actualizar()en cada vuelta: no debe pasar de un pequeño porcentaje del período. - Probá el arranque: encendé con el sensor desconectado y con el valor de reset, y verificá que no se enciende nada.
11 · Analogía con programación
Interfaz, drivers, pruebas y presupuesto de tiempo
Este tema es de diseño de software, así que el programa de Python no calcula una magnitud: reproduce la arquitectura. Una interfaz abstracta, dos drivers reales (un NTC con ADC y un DS18B20 con su registro de 16 bits) y uno simulado, una aplicación que solo conoce la interfaz, pruebas de las conversiones y una comparación de las decisiones con los tres drivers; al final, el costo de las combinaciones, del bloqueo y del desgaste del relé.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
import math
from abc import ABC, abstractmethod
OK, SIN_RESPUESTA, FUERA_DE_RANGO = "OK", "SIN_RESPUESTA", "FUERA_DE_RANGO"
# ---- La interfaz: lo único que conoce la aplicación ----
class SensorTemperatura(ABC):
@abstractmethod
def leer(self):
"""Devuelve (estado, grados Celsius o None)."""
# ---- Driver 1: NTC de 10 kΩ en divisor con un ADC ----
class NtcAdc(SensorTemperatura):
def __init__(self, adc, beta=3950.0, r0=10_000.0, t0=25.0, r_fija=10_000.0, bits=10):
self.adc, self.beta, self.r0, self.t0, self.r_fija = adc, beta, r0, t0, r_fija
self.maximo = 2 ** bits - 1
def leer(self):
cuentas = self.adc()
if cuentas <= 0 or cuentas >= self.maximo: # cable cortado o en cortocircuito
return FUERA_DE_RANGO, None
r = self.r_fija * cuentas / (self.maximo - cuentas)
t_k = 1 / (1 / (self.t0 + 273.15) + math.log(r / self.r0) / self.beta)
return OK, t_k - 273.15
# ---- Driver 2: DS18B20 (registro de 16 bits con signo, 1/16 °C por cuenta) ----
class Ds18b20(SensorTemperatura):
def __init__(self, leer_registro):
self.leer_registro = leer_registro
def leer(self):
raw = self.leer_registro()
if raw is None:
return SIN_RESPUESTA, None # no hay pulso de presencia
if raw == 0x0550:
return FUERA_DE_RANGO, None # 85,0 °C: valor de arranque, la conversión no ocurrió
if raw & 0x8000:
raw -= 0x10000
return OK, raw / 16
# ---- Driver 3: sensor simulado, para probar sin hardware ----
class Simulado(SensorTemperatura):
def __init__(self, valores):
self.valores, self.i = valores, 0
def leer(self):
v = self.valores[min(self.i, len(self.valores) - 1)]
self.i += 1
return (SIN_RESPUESTA, None) if v is None else (OK, v)
# ---- La aplicación: depende solo de la interfaz ----
class Termostato:
def __init__(self, sensor, consigna, histeresis):
self.sensor, self.consigna, self.h = sensor, consigna, histeresis
self.calefaccion = False
def paso(self):
estado, t = self.sensor.leer()
if estado != OK: # sin dato fiable: estado seguro
self.calefaccion = False
return "SEGURO", estado
if t < self.consigna - self.h:
self.calefaccion = True
elif t > self.consigna + self.h:
self.calefaccion = False
return ("ENCENDIDA" if self.calefaccion else "APAGADA"), t
# ---- Pruebas de las conversiones, sin hardware ----
estado, t = NtcAdc(lambda: 512).leer()
assert estado == OK and abs(t - 25.0) < 0.1
assert Ds18b20(lambda: 0x0191).leer() == (OK, 25.0625)
assert Ds18b20(lambda: 0xFF5E).leer() == (OK, -10.125)
assert Ds18b20(lambda: 0x0550).leer()[0] == FUERA_DE_RANGO
assert Ds18b20(lambda: None).leer()[0] == SIN_RESPUESTA
assert NtcAdc(lambda: 0).leer()[0] == FUERA_DE_RANGO
print(f"NTC con 512 cuentas: {NtcAdc(lambda: 512).leer()[1]:.3f} °C; DS18B20 0x0191: {Ds18b20(lambda: 0x0191).leer()[1]} °C; 0xFF5E: {Ds18b20(lambda: 0xFF5E).leer()[1]} °C")
print("Pruebas de conversión: todas correctas")
# ---- El mismo termostato con tres drivers distintos ----
serie = [22.0, 23.2, 23.4, 24.2, 24.6, 24.4, 23.6, 23.4, None, 24.0]
t_sim = Termostato(Simulado(serie), 24.0, 0.5)
print()
print("Termostato (consigna 24 °C, histéresis ±0,5 °C) con el sensor simulado:")
for v in serie:
decision, dato = t_sim.paso()
mostrado = f"{dato:.1f} °C" if isinstance(dato, float) else dato
print(f" entrada {str(v):>5s} -> {decision:9s} ({mostrado})")
def contar_a_cuentas(temp_c, beta=3950.0, r0=10_000.0, t0=25.0, bits=10):
r = r0 * math.exp(beta * (1 / (temp_c + 273.15) - 1 / (t0 + 273.15)))
return round((2 ** bits - 1) * r / (r + 10_000.0))
reales = [22.0, 23.2, 23.4, 24.2, 24.6, 24.4, 23.6, 23.4]
decisiones = {}
for nombre, factoria in (("NTC", lambda it: NtcAdc(lambda: contar_a_cuentas(next(it)))),
("DS18B20", lambda it: Ds18b20(lambda: round(next(it) * 16))),
("simulado", lambda it: Simulado(list(it)))):
app = Termostato(factoria(iter(reales)), 24.0, 0.5)
decisiones[nombre] = [app.paso()[0] for _ in reales]
print(f" {nombre:9s}", " ".join(f"{d[:3]}" for d in decisiones[nombre]))
print(" decisiones idénticas con los tres drivers:", len({tuple(v) for v in decisiones.values()}) == 1)
# ---- N x M frente a N + M ----
print()
print("sensores aplicaciones sin interfaz (N x M) con interfaz (N + M)")
for s, a in ((2, 2), (3, 4), (5, 5), (8, 8)):
print(f"{s:8d} {a:12d} {s * a:20d} {s + a:20d}")
# ---- Presupuesto de tiempo: lectura bloqueante frente a por etapas ----
print()
periodo = 10
for nombre, conversion in (("NTC por ADC", 0.1), ("DS18B20 de 9 bits", 93.75), ("DS18B20 de 12 bits", 750.0)):
perdidos = int(conversion // periodo)
print(f"{nombre:19s}: bloqueo de {conversion:7.2f} ms -> {perdidos:3d} ciclos de {periodo} ms perdidos; por etapas: 0")
print(f"cuatro DS18B20 de 12 bits leídos uno tras otro: {4 * 750 / 1000:.0f} s de bloqueo; si se lanzan todos a la vez y se espera una sola conversión: 0,75 s")
# ---- Relé con control proporcional en el tiempo: desgaste ----
print()
for ventana in (10, 60, 600):
ciclos_h = 3600 / ventana
print(f"ventana de {ventana:3d} s: {ciclos_h:6.1f} ciclos por hora -> {100_000 / ciclos_h:8.0f} h hasta 100 000 ciclos eléctricos ({100_000 / ciclos_h / 24 / 365:.2f} años)")
Resultado: las pruebas de conversión pasan (un NTC con 512 cuentas da 24,956 °C; el registro 0x0191 del DS18B20, 25,0625 °C y 0xFF5E, −10,125 °C; 0x0550, el valor de arranque, y un cable cortado se rechazan). El termostato con consigna de 24 °C e histéresis de ±0,5 °C enciende con 22; 23,2; 23,4 y 24,2 °C, apaga con 24,6 y se mantiene apagado con 24,4 y 23,6 °C, enciende de nuevo con 23,4 y, con el sensor sin respuesta, pasa a SEGURO y apaga la calefacción. Con el NTC, el DS18B20 y el simulado, las decisiones son idénticas: ENC ENC ENC ENC APA APA APA ENC. Con 5 sensores y 5 aplicaciones, adaptar cada combinación son 25 piezas frente a 10 con interfaz (8 y 8: 64 frente a 16). Un DS18B20 de 12 bits bloquea un lazo de 10 ms durante 75 ciclos (el de 9 bits, 9); cuatro leídos en serie son 3 s de bloqueo y lanzados a la vez, 0,75 s. Un relé con ventana de 10 s hace 360 ciclos por hora y llega a 100 000 en 278 h; con 600 s, 6 ciclos por hora y 1,90 años.
12 · Exploración interactiva
Combinaciones, sensor intercambiable, tiempos y actuador
El primer instrumento cuenta las piezas que exigen las combinaciones con y sin interfaz. El segundo muestra el mismo termostato con tres drivers de sensor distintos. El tercero compara una lectura bloqueante con una por etapas. El cuarto convierte una orden de potencia en la señal de tres drivers de salida. Los tiempos del relé usan 100 000 ciclos eléctricos de vida, un valor típico.
Instrumento 1: cuántas piezas hacen falta
Instrumento 2: el mismo termostato con tres sensores
Instrumento 3: presupuesto de tiempo del lazo
Instrumento 4: una orden, tres drivers de salida
Recorrido guiado
- Con 3 tipos de sensor y 4 aplicaciones: 12 piezas sin interfaz y 7 con interfaz, un ahorro del 42 %. Pasá a 8 y 8: 64 frente a 16 (75 %). Con 1 sensor, la interfaz no compensa todavía: es la regla de tres.
- Con 2 y 2, ambas cuestan lo mismo (4): la interfaz recién rinde cuando aparece el tercer sensor o la tercera aplicación. Cambiá el diseño y mirá cómo el dibujo pasa de una malla a una barra central.
- En el segundo instrumento, con el NTC a 22,0 °C y consigna de 24 °C con ±0,5: la aplicación enciende la calefacción. Cambiá a DS18B20 y al simulado con la misma temperatura real: la misma decisión, con otra resolución (el DS18B20 cuantiza de 0,0625 °C).
- Subí la temperatura a 24,2 °C: queda dentro de la banda y la calefacción mantiene su estado (la histéresis). A 25,0 °C se apaga; al bajar a 24,2 °C sigue apagada.
- Inyectá «cable cortado»: con el NTC la lectura es
FUERA_DE_RANGO(el ADC satura) y con el DS18B20,SIN_RESPUESTA. En los dos casos la aplicación pasa a estado seguro y apaga la calefacción. - Inyectá el «valor de arranque» con el DS18B20: sin el estado explícito, 85,0 °C parecería una temperatura real y apagaría la calefacción por error; el driver lo marca
FUERA_DE_RANGOy la aplicación lo trata como falla. - En el tercer instrumento, con el DS18B20 de 12 bits, un lazo de 10 ms y lectura bloqueante: 750 ms de bloqueo y 75 ciclos perdidos. Pasá a «por etapas»: ningún ciclo perdido y un dato que llega 750 ms después de pedirlo. Con 4 sensores bloqueantes, 3 s.
- En el cuarto, con 60 % y el relé con ventana de 600 s: 360 s encendido y 240 s apagado, 6 ciclos por hora, 1,90 años. Cambiá la ventana a 10 s: 360 ciclos por hora, 278 horas: el estado avisa del desgaste. La misma orden de la aplicación, con otro driver, se vuelve un pulso de 1600 µs en el servo.
Desafío: agregar un sensor de humedad sin tocar la aplicación
El termostato debe pasar a controlar también la humedad con un DHT22 (tema 12), que tarda 2 s entre lecturas y devuelve temperatura y humedad. ¿Qué cambia y qué no? No cambia la aplicación del termostato: sigue usando SensorTemperatura. Se agrega una interfaz SensorHumedad (porcentaje, mismo Lectura, mismo iniciar/lista/leer) y un driver Dht22 con dos vistas, una por cada magnitud, que comparten la misma lectura física: el período mínimo de 2 s se respeta dentro del driver, que devuelve DATO_VIEJO si se le pide antes de tiempo y no bloquea. Una segunda aplicación (Humidificador) usa SensorHumedad y una Salida; en la raíz de composición se crea un único Dht22 y se entregan sus dos vistas. Por el camino se confirma el valor de la interfaz: el tercer dispositivo y la segunda aplicación cuestan una pieza cada uno, no una por combinación.
13 · Método de trabajo
Cómo diseñar un driver, para cualquier dispositivo
- Escribí primero qué necesita la aplicación (unidad, rango, cada cuánto) y de ahí sacá la interfaz mínima.
- Definí el estado de error en la lectura: qué puede salir mal y cómo se informa, sin valores centinela.
- Partí lo lento en
iniciar(),lista()yleer(), con los tiempos del dispositivo documentados. - Aislá la conversión en una función pura y probala con valores conocidos.
- Pasá la configuración por el constructor y sacá los pines y constantes a
config.h; la calibración, a EEPROM con CRC. - Implementá el driver simulado junto con el real, y probá la aplicación con ambos y con fallas.
- Agregá filtros y calibración como decoradores, no dentro de cada driver ni de la aplicación.
- Cada salida con estado seguro, límites y protecciones propias; ensamblá todo en la raíz de composición.
Si cambiar un componente obliga a editar la aplicación, el contrato tiene una filtración; si el lazo se detiene, falta partir la lectura; si una falla enciende el calefactor, falta el estado; si no se puede probar sin la placa, la lógica toca la plataforma.
14 · Comprensión
Confusiones frecuentes
«Abstraer es complicar el código sin necesidad»
Con un solo sensor y una sola aplicación, una interfaz de más es ruido. Empieza a rendir con el segundo dispositivo o la segunda aplicación, y por eso la regla de tres: no se abstrae antes de tiempo ni se pospone indefinidamente.
«Un driver es lo mismo que una biblioteca»
Una biblioteca de un tercero es un ingrediente. El driver es la pieza propia que la envuelve y la adapta al contrato del proyecto, de modo que la aplicación no dependa de ella.
«Si la función devuelve −127 o 85, ya está»
Un valor centinela es una temperatura posible que la aplicación puede tomar por buena. Un estado explícito en la lectura obliga a tratar el caso.
«Las funciones virtuales son demasiado lentas para un microcontrolador»
Una llamada virtual agrega unas pocas instrucciones y un puntero por objeto. Frente a una lectura de sensor (desde 0,1 ms hasta 750 ms) es despreciable; no lo es el bloqueo de un delay.
«Para probar la lógica hace falta el hardware»
Con la lógica separada, se prueba con drivers simulados en el PC en segundos, incluidas fallas que en el montaje real son difíciles de provocar.
«El relé se puede manejar como un PWM rápido»
Un relé conmuta unas pocas veces por minuto como mucho: su vida es de unos 100 000 ciclos eléctricos. El control proporcional en el tiempo usa una ventana de minutos, y esa protección va en el driver.
15 · Práctica guiada
Predecí antes de comprobar
1. Combinaciones
Un proyecto soporta 4 tipos de sensor y 6 aplicaciones. ¿Cuántas piezas hacen falta sin interfaz y con ella?
Ver solución
Sin interfaz: 4 × 6 = 24; con interfaz: 4 + 6 = 10 (un ahorro del 58 %).
2. Conversión
Un DS18B20 devuelve el registro 0x0190. ¿Qué temperatura es? ¿Y 0x0550?
Ver solución
0x0190 = 400 → 400 / 16 = 25,0 °C. 0x0550 = 1360 → 85,0 °C, que es el valor de arranque: el driver debe marcarlo FUERA_DE_RANGO.
3. Tiempo bloqueado
Un lazo de control de 20 ms lee tres DS18B20 de 12 bits, uno tras otro y con espera. ¿Cuánto bloquea y cuántos ciclos pierde? ¿Y si se lanzan los tres a la vez y se lee por etapas?
Ver solución
3 × 750 = 2250 ms bloqueados → 2250 / 20 ≈ 112 ciclos perdidos. Por etapas, con una sola orden para los tres: 0 ciclos perdidos y el dato llega 750 ms después de pedirlo.
4. Histéresis
Con consigna de 20 °C e histéresis de ±1 °C, la calefacción está apagada y la temperatura baja a 19,5 °C, luego a 18,9 °C. ¿Cuándo se enciende?
Ver solución
Se enciende cuando la temperatura es menor que 20 − 1 = 19 °C: a 19,5 °C sigue apagada y a 18,9 °C se enciende.
5. Relé
Un control proporcional con ventana de 30 s al 50 %: ¿cuánto dura cada encendido, cuántos ciclos por hora y cuántas horas de vida con 100 000 ciclos?
Ver solución
15 s encendido y 15 s apagado; 3600 / 30 = 120 ciclos por hora; 100 000 / 120 ≈ 833 h (unas 5 semanas): demasiado desgaste. Con una ventana de 600 s serían 16 667 h.
6. Diagnóstico
Un termostato con DS18B20 apaga la calefacción durante unos segundos cada vez que se enciende el equipo, aunque la habitación está fría. ¿Qué pasa y dónde se corrige?
Ver solución
Al arrancar, el sensor devuelve 85,0 °C (valor de reset) y la aplicación lo toma por una temperatura real, mayor que la consigna: apaga. Se corrige en el driver, que debe devolver FUERA_DE_RANGO para ese valor, y la aplicación ya trata cualquier estado distinto de OK como falla (estado seguro).
16 · Síntesis
Ideas para recordar
- Un driver encapsula un dispositivo concreto; la capa de abstracción es el contrato que permite que la aplicación no lo conozca.
- Cinco capas: aplicación, interfaz, driver, plataforma y hardware; las dependencias apuntan hacia abajo y pasan por interfaces.
- Con interfaz, S sensores y A aplicaciones cuestan S + A piezas, no S × A; pero se abstrae al segundo dispositivo (regla de tres), no antes.
- La interfaz de un sensor devuelve unidades físicas, un estado explícito y una marca de tiempo, y no bloquea:
iniciar(),lista(),leer(). - Los valores centinela (−127, 85 °C) se toman por datos reales: el driver los convierte en estados y la aplicación pasa a estado seguro ante cualquier estado distinto de OK.
- En C++: clases abstractas para elegir en tiempo de ejecución, plantillas si se fija al compilar, decoradores para filtros y calibración, objetos estáticos y una raíz de composición.
- Las salidas tienen interfaz propia (
establecer,apagar,actualizar) y protecciones en el driver: ventana larga del relé, estado seguro al arrancar, lógica invertida oculta. - La configuración va aparte (
config.h, EEPROM con CRC), y la lógica se prueba sin hardware con drivers simulados, funciones puras de conversión y fallas inyectadas.
¿Listo para el tema 41?
Antes de avanzar, explicá cuántas piezas necesitan 3 sensores y 5 aplicaciones con y sin interfaz, por qué un driver no debe devolver −127 cuando falla y por qué iniciar(), lista() y leer() son mejores que un leer() que espera. Si podés justificar las tres respuestas, ya sabés diseñar drivers.
En el próximo tema llevamos lo que acabamos de ver (lecturas por etapas) a su forma general: la lectura y el control sin bloqueos, con tareas periódicas, máquinas de estados y el manejo del tiempo con millis().