Solución al ejercicio de código limpio y ordenado en C++ para Arduino


Ha terminado la semana dedicada al taller de refactorización, así que ahora toca ver una de las posibles soluciones al ejercicio que habíamos planteado.

Y decimos "una de las posibles soluciones" porque no existe una única solución correcta. Al contrario, existen infinitas soluciones, y cada una tendrá sus virtudes y defectos.

Además, potencialmente el ejercicio de refactorización podría no terminar nunca. Siempre va a ser posible ordenar más el código, cambiar la estructura de esto, abstraer aquello de allá, etc.

De hecho, en el caso de la solución que traigo he parado porque ya corríamos el riesgo de que dijerais "tío, esto se parece al código original lo que un huevo a una castaña". Y lo cierto es que parte razón no os faltaría.

Anuncio:

¡Importante! A lo largo de esta entrada vamos a diferenciar lo que es funcionalidad / proceso, de código / implementación. Que a muchos les parece lo mismo, pero no lo es. La funcionalidad es "cómo resuelves un problema", y podrías hacerla en una servilleta, o un pseudo código. El código es una de las posibles implementaciones para llevar a cabo la funcionalidad.

En el ejercicio de refactorización nos vamos a centrar en la estructura del código. El nuevo código mantiene idéntica la funcionalidad al original. Los procesos que hiciera el código original, son los mismos que hace el refactorizado. Las virtudes o errores que tuviera siguen estando aquí.

Incluso las incongruencias del proceso, que algún comentaremos al final. Aunque, ahora quizás son más evidentes porque el nuevo código lo deja ver mejor. Es una de los motivos para mantener limpio tu código, que deja ver más fácilmente errores en el proceso.

Por otro lado, también es importante tener en cuenta que la solución tiene carácter formativo. El objetivo no es hacer un código perfecto, si no presentar una serie de mecanismos, a ser posibles variados, para mantener limpio y ordenador el código.

Por último, antes de terminar esta introducción, quiero dar las gracias al autor del código original. Que no parezca que vamos a poner su código "a caldo". Nadie nace aprendido, y hemos cogido este proyecto porque es interesante. Así que, nuevamente, gracias al autor por compartir su trabajo con la comunidad.

Así que, una vez finalizado este tostón de introducción, empezamos con la refactorización con un análisis del ligero del código original.

Análisis del código original

Vamos a echarle un vistazo ligero al código original

  • Compuesto por un único fichero
  • Al principio tenemos un montón de variables globales y #defines
  • La mayor parte del código está en una muy larga función de setup() y una larguísima función de loop()
  • Sólo tiene 4 funciones. 2 porque las necesita para para gestionar las interrupciones y 2 para el control del dispositivo "Insteon"
  • La mayor parte del peso del programa está en el setup() y, sobre todo, en el loop().
  • El código se ejecuta linealmente, sin mayor tipo de orden que el de "ir una cosa detrás de otra".
  • Se mezcla código de distintas funcionalidades, como sensor de temperatura, entrada de usuario, comunicación, etc, sin separación alguna en la responsabilidad de cada parte.

Es decir, un claro ejemplo de código que funciona pero no está bien. De hecho, funciona de casualidad. Y si el autor tiene que modificar o reusar el código algún día, va a sudar tinta para saber qué hace cada parte.

¿Diagnóstico doctor? Código espagueti pero además del bueno, de libro. Solo le falta queso por encima. ¿Tratamiento propuesto? ¡Sacar las tijeras de podar!

Solución propuesta

Vamos a empezar a desemarañar este código poco a poco. Tal cuál como si fuera un nudo que se nos ha hecho en el cable de unos auriculares al meterlos en el bolsillo.

Así iremos viendo y presentando algunos mecanismos que podemos aplicar para obtener un código limpio, entendible, testeable, reusable, y mantenible.

Constantes

La primera, bien sencilla y evidente. Cogemos las constantes del programa, y las sacamos a ficheros independientes.

En este caso, definimos dos ficheros. 'Config.h' que contiene las constantes asociadas al proceso

#pragma once

const char* SSID = "YOUR_SSID";
const char* PASSWORD = "YOUR_PASSWORD";
const char* HOST = "192.168.0.XX";

const float DEFAULT_TEMPERATURE = 19;
const float DEFAULT_THRESHOLD = 1;
const float TURN_ON_THRESHOLD = 2;

const String TURN_OFF_URL = "http://192.168.0.XX:XXX/3?02621FF5870F11FF=I=3";
const String TURN_ON_URL = "http://192.168.0.XX:XXX/3?02621FF5870F11FF=I=3";

En este fichero veréis que han aparecido las constantes DEFAULT_TEMPERATURE, DEFAULT_THRESHOLD, y TURN_ON_THRESHOLD.

Todas estas constantes son claramente identificables por el que venga después simplemente con ver el nombre. De esta forma eliminamos ciertos "números mágicos" que aparecían en el código original.

No evaluamos si desde el punto de vista del proceso son correctas, tienen sentido definirlas así, o sería mejor de otra forma. No es propósito del ejercicio. El autor las usa, pues nosotros las mantenemos. Simplemente les estamos poniendo un nombre a algo que ya usa.

Por otro lado, creamos un fichero "Pinout.h", que recoge las definiciones de hardware en las que vamos a ejecutar el proyecto.

#pragma once

const uint8_t SWITCH_PIN = 14 ;// interrupt 0 Pin 2 in Arduino is a must due to interrupts
const uint8_t CLOCK_PIN = 13; // interrupt 1 Pin 3 in Arduino is a must due to interrupts
const uint8_t DATA_PIN = 11;

const uint8_t LED_PIN = 16;

const uint8_t DHT_PIN = 12;

Enums

El siguiente paso es crear las enumeraciones que están "rulando" por el proyecto. Creamos un fichero enum.h, y aquí metemos las enumeraciones que correspondan.

Así, por ejemplo identificamos las operaciones o acciones que realiza el proyecto, que son las siguientes.

enum COMMAND_OPERATION
{
    INCREASE,
    DECREASE,
    TURN_ON,
    TURN_OFF,
    SET_DEFAULT,
    SET_SETPOINT
};

Nuevamente (y para no ser "pesao" no voy a repetirlo más en esta entrada) no entramos a valorar si son correctas, o sobra, o faltan. Son las que usa el autor y nosotros las mantenemos. Aunque, probablemente, igual alguna fuera diferente si hubiera hecho el ejercicio de plantearse esto desde el principio.

Por otro lado, también encontramos como enum el resultado posible de una acción sobre el sistema,

enum COMMAND_RESULT
{
    EMPTY,
    VALID,
    INVALID,
    NOT_CONNECTED
};

Por último, identificamos como enum el estado en el que puede estar el proyecto.

enum THERMOSTAT_STATUS
{
    OFF,
    ON,
};

En este caso, únicamente contempla ON/OFF. Podíamos haber usado una variable bool, por ejemplo "isTurnedOn". Pero queda más claro si tenemos una enumeración, y dejamos la puerta a futuros estados adicionales (no iniciado, esperando, error, etc). Además, nos viene bien para el ejercicio de formación.

Modelo (parte I)

Ahora que ya hemos identificado las constantes y enumeraciones necesarias llega la parte de redefinir la arquitectura. O mejor dicho, de definirla, porque el código original realmente no tenía planteada una estructura.

Lo primero que vamos a hacer es abstraer un objeto, o "modelo" (o si os venís arriba, dominio) que encapsule la lógica de nuestro proceso. Por seguir con el nombre del proyecto original lo llamamos, por ejemplo, IotThermostat.

Este objeto representa "lo gordo" de nuestro proyecto. En el va a estar casi toda la lógica asociada al proceso. Es decir viene a reemplazar, sobre todo, el setup() y el loop(), y es la base que sustentará el resto del proyecto.

Jerárquicamente, el modelo estará por encima de todo el resto de elementos que vamos a crear. Por tanto, y como no podemos crear una casa empezando por el tejado, volveremos a el más adelante. De momento nos vale con saber que va a existir, y que es el 'core' de nuestro proyecto.

Componentes

A continuación, vamos a identificar los elementos que intervienen en nuestro proyecto, a nivel más bajo. Así, el proyecto contiene un Led, un encoder, un botón, un display, un sensor de temperatura y (aunque el código original no lo sabe) un control de umbral.

Para uno de estos elementos vamos a crear un objeto independiente. Normalmente les llamaría "classes" o "models". Pero, porque se entienda mejor, aquí los vamos a llamar "components".

Veréis que, al "sacarlos" del loop, en realidad el código de cada uno de estos objetos es mucho más sencillo de entender que cuando se encuentra enmarañado en un loop() sin fin.

Así, creamos una carpeta de "components" y, en su interior, los siguientes archivos.

components/Button.hpp
components/Encoder.hpp
components/Led.hpp
components/Display_I2C_Lcd.hpp
components/TemperatureSensor_DHT11.hpp
components/ThresholdController.hpp

El objetivo es crear componentes sencillos, que sean responsables únicos de una única funcionalidad. Es decir, el objeto "button", sólo se encarga de gestionar el botón. Si algo falla en el botón, debe ser el primer fichero a mirar. Idénticamente, si algo falla, pero no es el botón, no hace falta mirar este componente.

Estos componentes deben tener una débil dependencia con el proyecto. Es decir, no "deben saber" en que proyecto están montado. Por tanto son fácilmente reusables en otros proyectos, así como también es sencillo testear su funcionamiento de forma independiente.

Ahora vamos a ver cada uno de estos componentes.

ButtonComponent

Sin más, un simple botón que detecta una pulsación por interrupción, con un debounce simple.

Además, proporciona los métodos HasBeenPressed() y Restart() para… no hace falta que os lo cuente. Ventajas de poner nombres representativos a los métodos 😉

#pragma once

class ButtonComponent
{
public:
    void Init()
    {
        pinMode(SWITCH_PIN, INPUT_PULLUP);
        attachInterruptArg(digitalPinToInterrupt(SWITCH_PIN), ButtonComponent::GetISR, this, FALLING);
    }

    bool HasBeenPressed()
    {
        return pressedStatus;
    }

    void Restart()
    {
        pressedStatus = false;
        lastMillis = millis();
    }

    void ISR()
    {
        unsigned long lastMillis;
        if((unsigned long)(millis() - lastMillis) >= DEBOUNCE_MILLIS)
        {
            lastMillis = millis();

            pressedStatus = true;
        }
    }

private:
    volatile bool pressedStatus;

    const unsigned long DEBOUNCE_MILLIS = 100;
    unsigned long lastMillis;

    void static GetISR(void* switchComponent)
    {
        ((ButtonComponent*)switchComponent)->ISR();
    }
};

Importante para la arquitectura e independencia. Este componente se "comunica" con el resto, a través de sus métodos (en este caso HasBeenPressed). Nada de mezclar las variables internas, con el bucle principal o con otros objetos. Cada cosa separada y compartimentada.

EncoderComponent

Primo hermano del ButtonComponent, detecta por interrupción del encoder rotativo.

#pragma once

class EncoderComponent
{
public:
    void Init()
    {
        pinMode(CLOCK_PIN, INPUT);
        pinMode(DATA_PIN, INPUT);

        attachInterruptArg(digitalPinToInterrupt(CLOCK_PIN), EncoderComponent::GetISR, this, FALLING);
    }

    void SetCounter(int value)
    {
        counter = value;
    }

    bool HasChanged()
    {
        return change != 0;
    }

    int GetCounter()
    {
        return counter;
    }

    void IncreaseCounter()
    {
        counter++;
    }

    void DecreaseCounter()
    {
        counter--;
    }
    
    void Restart()
    {
        change = 0;
    }

    void ISR()
    {
        if((unsigned long)(millis() - lastMillis) >= DEBOUNCE_MILLIS)
        {
            lastMillis = millis();

            if(digitalRead(DATA_PIN) == LOW)
            {
                counter++;
            }
            else
            {
                counter--;
            }

            change++;
        }
    }

private:
    volatile int counter;
    volatile int change;

    const unsigned long DEBOUNCE_MILLIS = 100;
    unsigned long lastMillis;
    
    void static GetISR(void* encoderComponent)
    {
        ((EncoderComponent*)encoderComponent)->ISR();
    }
};

Nuevamente, el encoder se encarga de autogestionar su estado con sus variables internas. Nada de que alguien le lea cuando vale el EncoderCTR, y menos se lo reescriba.

La "comunicación" con el resto del programa se realiza a través de los métodos del component, marcando una barrera entre "esto es mio, esto es tuyo"

ThesholdController

Este componente puede parecer "nuevo", o que no existe en el código original. No es así, sí que existe, pero su responsabilidad (su código) se encuentra difuminado en el loop() principal.

Este componente se encarga de aplicar un control de doble umbral a una variable. Es altamente reusable entre proyectos y, al tener el código de forma independiente, es realmente fácil de implementar / comprobar.

class ThresholdController
{

public:
    float SetPoint = DEFAULT_TEMPERATURE;
    float Threshold = DEFAULT_THRESHOLD;

    void TurnOn()
    {
        isTurnedOn = true;
    }
    void TurnOff()
    {
        isTurnedOn = false;
    }

    bool IsTurnedOn()
    {
        return isTurnedOn;
    }

    bool IsTurnedOff()
    {
        return !isTurnedOn;
    }

    bool Update(float value)
    {
        if(IsTurnedOn() && value >= SetPoint + Threshold)
        {
            TurnOff();
        }

        if(IsTurnedOff() && value <= SetPoint - Threshold)
        {
            TurnOn();
        }
    }

private:
    bool isTurnedOn;
};

En este caso, la salida del controlador es una variable booleana. En primer lugar, porque este controlador no necesita más, no tiene que ser ampliado en el futuro. Y además, porque me va perfecto para explicar una cosa.

¿No podíamos usar nuestra enum de STATUS, que ya tiene un ON, OFF? Shh… escuchad esto… grabáoslo en la cabeza… ¿estáis escuchando? CATEGÓRICAMENTE NO.

Recordáis que estamos hablando todo el rato de lo importante que es mantener la jerarquía de la arquitectura y delimitar las relaciones entre objetos.

Si este componente, que por definición está en la "base" de la jerarquía y está diseñado para estar aislado, ser reusable, y autocontenido, hiciera referencia a una enum del proyecto global, estaríamos invirtiendo la pirámide. La base, haría referencia al objeto más gordo.

Eso, aparte de que haría imposible reusar el componente en otro proyecto, implicaría que, si a nivel superior necesito modificar la enum, estaría modificándolo a este también.

En todo caso, el ThresholdController debería usar su propia enum THRESHOLD_CONTROLLER_STATUS, diferente de THERMOSTAT_STATUS.

LedComponent

Por otro lado, tenemos el control del Led

class LedComponent
{
public:
    uint8_t Led_Pin;

    LedComponent(uint8_t led_Pin)
    {
        Led_Pin = led_Pin;
    }

    void Init()
    {
        pinMode(Led_Pin, OUTPUT);
        TurnOff();
    }

    void TurnOn()
    {
        digitalWrite(Led_Pin, HIGH);
    }

    void TurnOff()
    {
        digitalWrite(Led_Pin, LOW);
    }
};

Preguntaréis ¿es necesario crear un componente sólo para encender un Led? Bueno, no está de más, primero por limpieza y por seguir la arquitectura que estamos definiendo.

Pero, más importante, lo que hoy es encender un Led, mañana puede ser "parpadear tres veces" o encender una tira Led.

Al tenerlo encapsulado en un objeto, si fuera necesario, en un futuro podríamos cambiarlo, sin tener que modificar nada más del proyecto.

Display

Ahora vamos a ver el display temperatura. Aquí, nos vamos a venir un poco más arriba, porque nos viene perfecto para explicar el papel de una clase abstracta o interface.

En el proyecto original se emplea una pantalla LCD conectada por I2C. Esto es meter una dependencia muy fuerte con el hardware para el proyecto.

Idealmente, a nosotros nos gustaría que el "model" (dominio, core) de nuestro termostato funcionara con cualquier pantalla. Es decir, queremos cargarnos esta dependencia.

Para ello, en realidad, nuestro "Termostato" no necesita una pantalla. Únicamente necesita algo que le permita llamar a un método Show() para mostrar la temperatura y la consigna.

Así que creamos una carpeta llamada "bases" y dentro creamos una clase IDisplay, que contenga los métodos que necesitamos.

#pragma once

class IDisplay
{
public:
    void virtual Init() = 0;

    void virtual Show(float temp, float target) = 0;
};

Este será el objeto que usará nuestro programa, una implementación abstracta de un display, no un display en particular.

Para poder usar un display creamos un nuevo objeto, aplicando un patrón de adaptador. Este objeto se llamará DisplayComponent, e implementa IDisplay.

#pragma once

#include "../bases/IDisplay.hpp"

class DisplayComponent_I2C_Lcd : public IDisplay
{
public:
    DisplayComponent_I2C_Lcd()
    {
        Lcd = nullptr;
    }

    DisplayComponent_I2C_Lcd(LiquidCrystal_I2C& lcd)
    {
        Lcd = &lcd;
    }

    void Init()
    {
        Lcd->backlight();
        Lcd->clear();

        Lcd->print("Room Temp:");
        Lcd->setCursor(0, 1);
        Lcd->print("Setpoint:");
    }

    void Show(float temp, float target)
    {
        Lcd->setCursor(11, 0);
        Lcd->print(temp, 0);
        Lcd->print(" C ");
        Lcd->setCursor(11, 1);
        Lcd->print(target);
        Lcd->print(" C ");
    }
private:
    LiquidCrystal_I2C* Lcd;
};

Sensor de temperatura

De forma similar, el proyecto original sirve para un único sensor de temperatura, un DHT11. Pero, ¿y si quisiéramos otro tipo de sensor de temperatura? O incluso, algo que no fuera un sensor de temperatura, si no otra cosa.

Pues, hacemos lo mismo que para el display. Creamos una clase abstracta, o interface, ISensor.

#pragma once

class ISensor
{
public:
    void virtual Init() = 0;

    float virtual Read() = 0;
};

A continuación, hacemos una implementación concreta del sensor, en un sensor de temperatura DHT11.

#pragma once

#include "../bases/ITemperatureSensor.hpp"
#include <MeanFilterLib.h>

class TemperatureSensorComponent_DHT11: public ITemperatureSensor
{
public:
    TemperatureSensorComponent_DHT11() : meanFilter(10)
    {
    }
    
    MeanFilter<float> meanFilter;

    DHTesp dht;

    void Init()
    {
        dht.setup(DHT_PIN, DHTesp::DHT11);
    }

    float Read()
    {
        auto rawTemperature = dht.getTemperature();
        auto humidity = dht.getHumidity();  //TODO: Esto no lo usa para nada
        auto temperature = meanFilter.AddValue(rawTemperature);

        Serial.print("\tAVG:\t");
        Serial.println(temperature, 0);

        return temperature;
    }
};

Aquí, por ver variedad, vamos a emplear una solución distinta a IDisplay. Este, recogía la pantalla original como un parámetro en el constructor. En este caso, hemos optado por una solución más sencilla, que es que el TemperatureSensorComponent_DTH11 instancia internamente el DTH11.

Nota, en este punto sí podríamos decir que me he "excedido" de lo que es una refactorización. Pero, en primer lugar, el código original era una ponzoña. Por otro lado, tengo una librería que es mía, que me sirve, y que está testeada y requeté comprobada.

Así me sirve para ilustrar un ejemplo habitual. Cuando tienes una serie de funciones que se usan de forma repetitiva y tú (o un grupo de mejora de un equipo de desarrollo) invierte un tiempo crear una librería que las contenga.

En este caso, modificar el programa para adaptarlo a las nuevas librerías, se considera refactorización (aunque daría para debate). No estás modificando su funcionalidad, simplemente la parte de código que realiza este proceso ahora está mantenido en una librería externa. 

Servicios

Ya tenemos nuestros objetos básicos, o componentes, que se identifican con las distintas partes elementales que intervienen en el programa.

Ahora tenemos otras partes, no tan simples, que también están identificados con una funcionalidad específica. Por ejemplo, tenemos que gestionar la interacción del usuario, y la comunicación.

Necesitamos elementos de nuestro programa que gestionen estas funcionalidades o, mejor dicho, estas áreas de funcionalidad. Y cada uno de estos objetos responsables de un área funcional le voy a llamar "servicios" (¡con un par!).

En general una buena parte de la lógica de proceso recaerá en estos servicios. Por tanto, lógicamente, será más difícil que sean reusables entre programa. Podrán reusarse, pero normalmente con algún cambio, porque el proceso será distinto.

Pero eso no significa que no vayamos a hacer un esfuerzo por seguir manteniendo las dependencias entre objetos lo más limpias posible. Así que, por ejemplo, para comunicarse entre ellos, vamos a crear un objeto que llamaremos, por ejemplo, "Command", que modeliza una acción que tenemos que realizar en nuestro proyecto.

Para ello, creamos una estructura CommandBase. Esta contiene el resultado del comando, y la operación a realizar.

#pragma once

#include "../constants/enums.hpp"

struct CommandBase
{
    COMMAND_RESULT Result;
    COMMAND_OPERATION Operation;
};

Ahora, creamos una nueva carpeta que llamaremos "services" y creamos estos archivos

services/UserInputService.hpp
services/ComunicationService.hpp

UserInputService

Este Servicio se encargará de todo lo relacionado con las entradas de usuario. En el caso del proyecto, a través del encoder y del pulsador.

#pragma once

#include <WiFi.h>

#include "../constants/enums.hpp"

struct UserInputCommand : public CommandBase {};

class UserInputService
{
public:
    UserInputCommand HandleUserInput(EncoderComponent encoder, ButtonComponent button)
    {
        UserInputCommand result;
        result.Result = COMMAND_RESULT::EMPTY;

        CompoundUserInputCommand(result,  HandleUserInput(encoder));
        CompoundUserInputCommand(result,  HandleUserInput(button));
        
        return result;
    }

    UserInputCommand HandleUserInput(EncoderComponent encoder)
    {
        UserInputCommand result;
        result.Result = COMMAND_RESULT::EMPTY;

        if(encoder.HasChanged())
        {
            result.Result = COMMAND_RESULT::VALID;
            result.Operation = COMMAND_OPERATION::SET_SETPOINT;
        }

        return result;
    }

    UserInputCommand HandleUserInput(ButtonComponent button)
    {
        UserInputCommand result;
        result.Result = COMMAND_RESULT::EMPTY;

        if(button.HasBeenPressed())
        {
            result.Result = COMMAND_RESULT::VALID;
            result.Operation = COMMAND_OPERATION::SET_DEFAULT;
        }

        return result;
    }

private:
    UserInputCommand CompoundUserInputCommand(UserInputCommand base, UserInputCommand candidate)
    {
        UserInputCommand compounded = base;

        if(candidate.Result == COMMAND_RESULT::VALID)
        {
            compounded = candidate;
        }
        
        return compounded;
    }
};

Veamos la "comunicación" del módulo con el resto del programa. En primer lugar, el servicio necesita un encoder y un pulsador. Tenemos varias formas de "pasar" estos objetos desde el programa principal al servicio.

Podríamos, por ejemplo, pasárselos en el constructo (inyección), definirlos como variables y asociarlos en el Init(). En este caso vamos a, simplemente, pasarlo como parámetros en las funciones "handleXXX".

De esta forma, aprovechamos para enseñar una sobrecarga para ver que nuestro servicio podría funcionar tanto únicamente con un pulsador, como con un encoder, como con ambos.

La comunicación del servicio con el resto del programa se realiza a través de un UserInputCommand, que hereda de CommandBase. ¿Por qué heredamos de un objeto, si no añadimos ni modificamos nada? Para que el compilador me avise si en algún momento me da por mezclar peras con manzanas.

Finalmente, mencionar que tenemos un método CompoundUserInput, que hace (adivinad, nombres representativos again). Solo usamos este método en este caso pero, si fuera una necesidad frecuente (que lo será) deberíamos plantearnos que CommandXXX deje de ser una struct y pase a ser una clase, y que el método Compound sea un método de la misma.

ComunicationService

Análogamente, el Servicio de comunicación encapsula toda la lógica del proyecto asociada a la comunicación Web. Realmente no me voy a parar a analizar mucho esto, porque era una de las partes más flojas del proyecto original.

Simplemente que sepáis que aquí está todo el código relativo a comunicación tal cual estaba. Aunque probablemente sea fácilmente mejorable, no es nuestro objetivo hoy entrar a cambiar esto.

#pragma once

#include <WiFi.h>

#include "../constants/enums.hpp"

struct CommunicationCommand : public CommandBase {};

class ComunicationService
{
public:
    ComunicationService(int port) : server(port)
    {
    }

    void Start()
    {
        Serial.println();
        Serial.print("Connecting to ");
        Serial.println(SSID);

        WiFi.begin(SSID, PASSWORD);
        while(WiFi.status() != WL_CONNECTED)
        {
            delay(500);
            Serial.print(".");
        }

        Serial.println("");
        Serial.println("WiFi connected");
        Serial.println(WiFi.localIP());

        StartServer();
    }

    void StartServer()
    {
        server.begin();
        Serial.println("Server started");
    }

    CommunicationCommand ProcessRequest(String request)
    {
        CommunicationCommand result;
        result.Result = COMMAND_RESULT::EMPTY;

        if(request.indexOf("") != -10)
        {
            if(request.indexOf("/+") != -1)
            {
                result.Operation = COMMAND_OPERATION::DECREASE;
            }
            if(request.indexOf("/-") != -1)
            {
                result.Operation = COMMAND_OPERATION::DECREASE;
            }
            if(request.indexOf("/ON") != -1)
            {
                result.Operation = COMMAND_OPERATION::TURN_ON;
            }
            if(request.indexOf("/OFF") != -1)
            {
                result.Operation = COMMAND_OPERATION::TURN_OFF;
            }
        }
        else
        {
            result.Result = COMMAND_RESULT::INVALID;
        }

        return result;
    }

    String response = "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html\r\n\r\n"
        "<!DOCTYPE HTML>\r\n<html>\r\n"
        "<p>Setpoint Temperature <a href='/+'\"><button>+</button></a> <a href='/-'\"><button>-</button></a></p>"
        "<p>Boiler Status <a href='/ON'\"><button>ON</button></a> <a href='/OFF'\"><button>OFF</button></a></p>";

    void SendResponse(WiFiClient& client, const float temperature, const float setpoint)
    {
        client.flush();
        client.print(response);

        client.println();
        client.print("Room Temperature = ");
        client.println(temperature);
        client.println();
        client.print("Setpoint = ");
        client.println(setpoint);
        delay(1);
    }

    CommunicationCommand HandleCommunications(const float temperature, const float setpoint)
    {
        CommunicationCommand result;
        WiFiClient client = server.available();

        if(!client)
        {
            result.Result = COMMAND_RESULT::NOT_CONNECTED;
        }

        // TODO: esta espera bloqueante no tiene sentido
        while(!client.available())
        {
            delay(1);
        }

        auto req = client.readStringUntil('\r');
        client.flush();

        auto command = ProcessRequest(req);
        if(command.Result == COMMAND_RESULT::INVALID)
        {
            // TODO: este stop tampoco tiene sentido
            client.stop();
        }
        else
        {
            result = command;
            SendResponse(client, temperature, setpoint);
        }

        return result;
    }

private:
    WiFiServer server;
};

Como cosas que sí nos importan, la "comunicación" del servicio con el resto del programa. Para iniciar el servicio necesitamos el puerto, que nos viene muy bien para presentar cómo pasarlo desde el constructor.

La comunicación del servicio al resto del programa se realiza, nuevamente, con un CommunicationCommand, que hereda de CommandBase

InsteonAPI

Por último, tenemos el fichero 'InsteonAPI', que contiene las llamadas a realizar para interactuar con el dispositivo Insteon.

#pragma once

#include "constants/config.h"

class InsteonApi
{
public:
    static void SetOn()
    {
        PerformAction(TURN_ON_URL);
    }

    static void SetOff()
    {
        PerformAction(TURN_OFF_URL);
    }

private:
    static void PerformAction(const String& URL)
    {
        HTTPClient http;

        http.begin(URL);
        http.addHeader("Content-Type", "text/plain");

        int httpCode = http.POST("Message from ESP8266");
        String payload = http.getString();

        Serial.println(httpCode);
        Serial.println(payload);

        http.end();
    }
};

Tampoco es que hubiera mucha "chicha" en el código original al respecto. Así que, simplemente hemos encapsulado las llamadas como métodos estáticos de una clase.

Bueno, es más o menos frecuente que las llamadas a API's o a DB's estén en objetos puramente estáticos. Sería debatible. Personalmente el cuerpo me pide meterlo en un objeto. Así podríamos heredad de un objeto base, y en el futuro poder interactuar con más tipos de fabriantes, etc. En cualquier caso, no creo que aporte mucho más que lo anterior a este ejercicio, así que así se queda. Si queréis modificarlo, os lo dejo como propuesta.

Pero, sobre todo, me sirve como "excusa" enseñar otra posible solución para encapsular código, más similar a las "funciones normales" de un sketch de Arduino. Simplemente podéis ponerlas como métodos estáticos en un objeto y así quedan más ordenaditas.

Y si no queréis crear el objeto ni lo hagáis, simplemente meterlas en un fichero. Pero no mezcléis en un fichero todas las cosas juntas. ¡Un mínimo de orden, leñe!

Modelo (parte II)

Ahora que hemos visto todos los elementos de la solución, podemos volver a nuestro "objeto gordo", es decir, al modelo o dominio de IotThermostat.

#pragma once

#include "constants/config.h"
#include "constants/pinout.h"
#include "constants/enums.hpp"

#include "bases/CommandBase.hpp"
#include "bases/ITemperatureSensor.hpp"
#include "bases/IDisplay.hpp"

#include "components/Led.hpp"
#include "components/Encoder.hpp"
#include "components/Button.hpp"
#include "components/Display_I2C_Lcd.hpp"
#include "components/TemperatureSensor_DHT11.hpp"
#include "components/ThresholdController.hpp"

#include "services/UserInputService.hpp"
#include "services/ComunicationService.hpp"

#include "InsteonApi.h"

class IotThermostatModel
{
public:
    IotThermostatModel() : communicationService(303), led(LED_PIN)
    {
    }

    void Init(IDisplay& idisplay, ITemperatureSensor& itempSensor)
    {
        display = &idisplay;
        temperatureSensor = &itempSensor;

        display->Init();
        temperatureSensor->Init();
        encoder.Init();
        button.Init();

        communicationService.Start();
    }

    float GetTemperature()
    {
        ReadTemperature();
        return currentTemperature;
    }

    void SetSetpoint(float value)
    {
        controller.SetPoint = value;
        encoder.SetCounter(value);
    }

    float GetSetPoint()
    {
        return controller.SetPoint;
    }

    void Run()
    {
        auto userInputResult = inputService.HandleUserInput(encoder, button);
        ProcessCommand(userInputResult);

        ReadTemperature();

        UpdateControllerStatus();

        auto communicationResult = communicationService.HandleCommunications(currentTemperature, GetSetPoint());
        ProcessCommand(communicationResult);

        display->Show(currentTemperature, GetSetPoint());
    }

private:
    void ReadTemperature()
    {
        currentTemperature = temperatureSensor->Read();
    }

    void UpdateControllerStatus()
    {
        auto isControllerTurnedOn = controller.Update(currentTemperature);
        auto newStatus = isControllerTurnedOn ? THERMOSTAT_STATUS::ON : THERMOSTAT_STATUS::OFF;

        if(status != newStatus)
        {
            PerformStatusChange(newStatus);
            status = newStatus;
        }
    }

    void PerformStatusChange(THERMOSTAT_STATUS status)
    {
        switch(status)
        {
        case OFF:
            InsteonApi::SetOff();
            led.TurnOff();
            break;

        case ON:
            InsteonApi::SetOn();
            led.TurnOn();
            break;

        default:
            break;
        }
    }

    void ProcessCommand(CommandBase command)
    {
        if(command.Result == COMMAND_RESULT::VALID);
        {
            ProcessOperation(command.Operation);
            Serial.println(controller.SetPoint);
        }
    }

    void ProcessOperation(COMMAND_OPERATION operation)
    {
        switch(operation)
        {
        case SET_DEFAULT:
            SetSetpoint(DEFAULT_TEMPERATURE);
            break;

        case SET_SETPOINT:
            SetSetpoint(encoder.GetCounter());
            break;

        case INCREASE:
            encoder.DecreaseCounter();
            Serial.println("You clicked -");
            break;

        case DECREASE:
            encoder.IncreaseCounter();
            Serial.println("You clicked -");

        case TURN_ON:
            SetSetpoint(currentTemperature + TURN_ON_THRESHOLD);
            Serial.println("You clicked Boiler On");
            break;

        case TURN_OFF:
            SetSetpoint(DEFAULT_TEMPERATURE);
            Serial.println("You clicked Boiler Off");
            break;

        default:
            break;
        }
    }

    THERMOSTAT_STATUS status = THERMOSTAT_STATUS::OFF;

    EncoderComponent encoder;
    ButtonComponent button;
    LedComponent led;

    IDisplay* display;
    ISensor* temperatureSensor;

    ThresholdController controller;

    UserInputService inputService;
    ComunicationService communicationService;

    float currentTemperature;
};

En forma resumida, tenemos las variables internas que hacen referencia a componentes y servicios, y configuran el "esqueleto" del programa. Estos se inician a través de los métodos oportunos, en el propio Init(…) del objeto.

Como método más importante tenemos el Run(), que realiza el trabajo del IotThermostat. Es decir, recibir los input del usuario, la comunicación web, leer la temperatura, actualizar el controlador, y realizar las acciones oportunas si hay un cambio de estado.

El funcionamiento desde el skecth principal sería el siguiente.

#include <WiFi.h>
#include <HTTPClient.h>

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <DHTesp.h>

#include "IotThermostatModel.hpp"

LiquidCrystal_I2C lcd(0, 0, 0);
DisplayComponent_I2C_Lcd display(lcd);

TemperatureSensorComponent_DHT11 dht;

IotThermostatModel iotThermostat;

void setup()
{
    Serial.begin(115200);
    delay(10);

    iotThermostat.Init(display, dht);
}

void loop()
{
    iotThermostat.Run();
    delay(1000);
}

¿A que ha quedado un sketch super pequeño monin y cuco? Como punto destacable, únicamente remarcar que estamos inicializando la pantalla, y pasándosela como inyección en el constructor al wrapper de IDisplay.

Por el contrario, en el sensor de temperatura hemos optado por otra solución (no requiere el objeto, si no que lo instancia internamente) no es necesario este paso.

Pensamientos finales

Hemos aprovechado este ejercicio de refactorización para presentar varias formas y mecanismos para organizar y mantener nuestro código limpio.

Por supuesto, no estoy diciendo ni que tengáis que hacerlo así, ni que el código no pudiera mejorarse más. Podríamos seguir mejorándolo y añadiendo interfaces hasta que pudiera estar en un museo de artes abstracto. Podriamos darle una vuelta a los Serial.print(...) que hay por todos lados, y a muchas cosas más.

Hay que tomárselo como un ejercicio de formación, donde he intentado que se viera un poco de variedad y hacerlo interesante. Llegado a este punto un par de reflexiones.

Arquitectura

Hemos definido una arquitectura básica de capas, donde tenemos un modelo, servicios, y componentes.

En esta "mini arquitectura", nuestros componentes se identifican con las distintas partes elementales que intervienen en el programa. Son fácilmente testeables, reusables, responsables de su propia funcionalidad, y son independientes del proyecto global (modelo). Son la "base de la pirámide".

También tenemos , tenemos otras partes del programa, que están identificados con una funcionalidad específica. Por ejemplo, tenemos que gestionar la interacción del usuario, y la comunicación. Los hemos llamado servicios.

Finalmente, tenemos nuestro objeto principal, o "modelo", va a usar los componentes y los servicios para realizar su trabajo. Los servicios, así mismo, también pueden usar los componentes.

A diferencia de los componentes, que son asépticos respecto al proceso global, los servicios si tienen una correlación bastante fuerte con el proceso. Es decir, es difícil que sean utilizables en otro proyecto, porque obedecen a un diseño.

Por si no lo veis, estamos usando una estructura de pirámide, con los componentes abajo, encima los servicios, y en la cima el modelo. Lo que viene siendo introduciendo una arquitectura de capas sencillita, de toa la vida y para toda la familia.

Podríamos mejorar (y complicar) mucho esta estructura. Pero nos sirve para ver por lo menos un principio de estructura y presentar principios básicos del código organizado como,

  • Divide y vencerás
  • Principio de responsabilidad única
  • Control (¡obsesivo!) de las relaciones y dependencias entre elementos

Refactorizar

El código tiene exactamente el mismo funcionamiento que el original. Lo creáis o no su comportamiento y funcionalidad es idéntico al original. Únicamente hemos límpido el código (mucho).

Otra cosa es que, al limpiar el código, alguna sorpresita nos salte a la cara.

void Run()
{
	auto userInputResult = inputService.HandleUserInput(encoder, button);
	ProcessCommand(userInputResult);

	ReadTemperature();

	UpdateControllerStatus();

	auto communicationResult = communicationService.HandleCommunications(currentTemperature, GetSetPoint());
	ProcessCommand(communicationResult);

	display->Show(currentTemperature, GetSetPoint());
}

¿Tiene sentido ese orden? ¿Ha tenido en cuenta que orden recibida, por input o wifi, tiene prioridad? ¿Que pasa si entre ejecución del bucle pasan 2 min? Pues seguramente no. Por otro lado, en la comunicación WiFi, tiene una espera bloqueante ¿que tiene mucho sentido? Pues básicamente tampoco.

Al tener el código ordenador, estos defectos de proceso quedan mucho más visibles. Y, además, es muchísimo más fácil mejorarlo. La función Run(), por ejemplo, puede mejorar mucho simplemente cambiando el orden de 4 líneas ;).

Agile, KISS

Por ejemplo, podríamos plantearnos modificar el código para que añadir más tipos de sensores, o distintos tipos de Api. O que el usuario pueda interactuar con la máquina con otro tipo de dispositivos, un joystick, una pantalla capacitiva, etc.

Por ejemplo, podríamos plantearnos que el UserInputService tuviera un vector de Input que hereden de una clase común IInput, y que podamos añadir dinámicamente según bla bla bla

O podríamos hacer que funcionara con distintos tipos de dispositivos, no solo de Insteon. Así que crearíamos una clase común ITurnableDevice, que representara un dispositivo que tiene ON() y OFF() y que hereadarían el resto y bla bla bla bla.

La cosa es que, en realidad, no nos lo han pedido ninguna de esas funcionalidades. Y según el Agile si una funcionalidad no es requerida no debemos implementarla. En general, se aconseja, abstraer interface de objetos la primera vez que lo necesitéis.

Sin embargo, también hay que tener dos dedos de frente al aplicar Agile. Hay casos en los que se ve muy muy claro, y el código te pide una clase abstracta a gritos. En mi opinión, el caso de quitar la dependencia con IDisplay e ISensor era evidente. Además cuesta poco esfuerzo y me ha servido para explicar el concepto.

Más allá de eso, lo más importante es que ahora nuestro código es fácilmente amplible y mantenible. Si un futuro tuvieramos que añadir alguna de estas funcionalidades, clases abstractas, etc etc, sabemos exáctamente donde tendríamos que ir a modificarlo y cómo hacerlo. Eso SÍ es Agile.

Limpieza de código

Hemos limpiado el código, siguiendo la mayoría de consejos que vimos en esta entrada https://www.luisllamas.es/4-consejos-para-programar-codigo-mas-limpio-en-arduino/ y que son algunas de las buenas prácticas habituales en Agile.

Hemos usado nombres de variables y funciones significativos. Hemos usado funciones cortitas, que se encargan de una única cosa. Hemos definido nuestros enums, nuestras constantes. El código se entiende al leerlo. Los consejitos que vemos siempre.

¿Sabéis lo que no hay? Ni un sólo comentario. Y ni falta que hacen, porque el objetivo es que el código se entienda al leerlo. Aunque en un proyecto real habría que documentar las clases y las funciones. ¿Pero comentarios en medio de una función? Aaah no.

¿Sabéis lo que tampoco hay? Ni una directiva de precompilador. Ni un #define, ni un #ifdef, ni nada. Y eso que hemos hecho un programa que sirve para distintos hardware. Pero no hemos tenido que meter #ifdef DHT11 ... en medio. Hemos usado clases abstractas. Minimizar el uso de las macros.

Eficiencia

Alguno me dirá, pero Luis, has pasado de un código a unas 300 líneas, a uno de ¿en torno a 1000?. Eso es tiene que tener un impacto en la eficiencia del programa y el espacio. ¿Seguro? Vamos a mirarlo.

Código original

//Program size: 888.322 bytes (used 68% of a 1.310.720 byte maximum) (26,15 secs)
//Minimum Memory Usage: 40176 bytes (12% of a 327680 byte maximum)

Código refactorizado

//Program size: 887.634 bytes (used 68% of a 1.310.720 byte maximum) (15,41 secs)
//Minimum Memory Usage: 40216 bytes (12% of a 327680 byte maximum)

Es decir, el código "limpio" ocupa 688 bytes menos de memoria y (en mi ordenador) compila en 15 segundos frente a 26 ¿Cómo puede ser eso ooooh? Pues así es.

Por el contrario, consume 40 bytes más de memoria dinámica. Que es un ridículamente pequeño incremento, a cambio de (por ejemplo) poder funcionar con varias pantallas, o varios sensores.

En cualquier caso, lo que os digo siempre. Escribid código limpio, ordenador, y mantenible. No intentéis hacer el trabajo del compilador, que es mucho más listo y se le da mucho mejor que a nosotros.

Conclusión

Hasta aquí el taller de refactorización. Gracias a todos los que habéis participado, y espero que lo hayáis encontrado interesante y divertido.

¿Vuestra solución se parecía a esta? ¿Tenía algún elemento en común? ¿Os habíais planteado alguno de los problemas que hemos visto?

El Discord le contaré a cada uno que ha pasado su solución lo puntos positivos y a mejorar. ¡Gracias a todos por participar!



Descarga el código

Todo el código de esta entrada está disponible para su descarga en Github.


0 0 votes
Article Rating

Anuncio:

Previous M5Stack Unit V2, un mini PC con cámara para aplicaciones de IA
This is the most recent story.
1 Comment
oldest
newest
Inline Feedbacks
View all comments
javier
11 days ago

Muy interesante. Muchas gracias