Conectando el contador de agua a la casa inteligente

Alguna vez, los sistemas de automatización del hogar, o como se les suele llamar "smart home", eran extremadamente caros y solo podían permitírselo los ricos. Hoy en día, en el mercado se pueden encontrar conjuntos bastante asequibles con sensores, botones/interruptores y dispositivos ejecutores para controlar la iluminación, enchufes, ventilación, abastecimiento de agua y otros consumidores. Incluso el más torpe de los entusiastas del DIY puede acercarse a lo maravilloso y, sin gastar mucho, ensamblar dispositivos para el hogar inteligente.

Conectando el contador de agua a la casa inteligente

Por lo general, los dispositivos ofrecidos son sensores o mecanismos ejecutores. Permiten implementar fácilmente escenarios como "activar la luz al detectar movimiento" o "el interruptor cerca de la salida apaga la luz en todo el apartamento". Sin embargo, no ha habido mucho progreso con la telemetría. En el mejor de los casos, esto equivale a un gráfico de temperatura y humedad, o la potencia instantánea en un enchufe específico.

Recientemente, instalé medidores de agua con salida de pulso. Cada litro que pasa por el medidor activa un sensor magnético y cierra el circuito. Solo queda lo fácil: conectar los cables y tratar de obtener algo útil de esto. Por ejemplo, analizar el consumo de agua por horas y días de la semana. Y si hay varios conductos de agua en el apartamento, es más conveniente ver todas las lecturas en una sola pantalla que estar buscando en rincones difíciles de alcanzar con una linterna.

A continuación, mi variante de un dispositivo basado en ESP8266, que cuenta los pulsos de los medidores de agua y envía las lecturas al servidor del hogar inteligente vía MQTT. Programaremos en micropython utilizando la biblioteca uasyncio. Al crear el firmware, encontré algunas dificultades interesantes, de las que también hablaré en este artículo. ¡Vamos allá!

Esquema

Conectando el contador de agua a la casa inteligente

El corazón de todo el esquema es un módulo con microcontrolador ESP8266. Inicialmente estaba previsto utilizar ESP-12, pero el mío resultó defectuoso. Tuve que conformarme con el módulo ESP-07 que tenía disponible. Afortunadamente, son idénticos en pines y funcionalidad, la única diferencia es la antena: el ESP-12 tiene una antena interna, mientras que el ESP-07 tiene una externa. Sin embargo, incluso sin antena, la señal de WiFi se recibe bien en mi baño.

El cableado del módulo es estándar:

  • un botón de reinicio con pull-up y condensador (aunque ambos ya están dentro del módulo)
  • la señal enable (CH_PD) está conectada a la alimentación
  • GPIO15 conectado a tierra. Esto solo es necesario al inicio, pero de todos modos no tengo nada más que conectar a este pin.

Para poner el módulo en modo de programación, es necesario conectar GPIO2 a tierra, y para mayor comodidad he previsto un botón de inicio. En condiciones normales, este pin se conecta a la alimentación.

El estado de la línea GPIO2 se verifica solo al inicio del funcionamiento: al encender o inmediatamente después de un reinicio. Así, el módulo se carga de manera habitual o entra en modo de programación. Después de la carga, este pin se puede utilizar como un GPIO normal. Y dado que ya hay un botón, se puede utilizar para una función útil.

Para programar y depurar, utilizaré UART, que he llevado a la tira. Cuando sea necesario, simplemente conecto un adaptador USB-UART allí. Solo debo recordar que el módulo funciona a 3.3V. Si olvido cambiar el adaptador a este voltaje y le suministro 5V, el módulo probablemente se quemará.

No tengo problemas con la electricidad en el baño: el enchufe está ubicado a aproximadamente un metro de los contadores, así que alimentaré desde 220V. Como fuente de alimentación, utilizaré un pequeño bloque HLK-PM03 de Tenstar Robot. Personalmente, tengo dificultades con la electrónica analógica y de potencia, y aquí hay una fuente de alimentación lista en un carcasa pequeña.

He previsto un LED conectado a GPIO2 para señalizar los modos de operación. Sin embargo, no lo solderé, ya que el módulo ESP-07 ya tiene un LED, que está conectado al mismo GPIO2. Pero en la placa dejaré uno: puede que quiera sacar ese LED al exterior.

Pasemos a lo más interesante. Los contadores de agua no tienen ninguna lógica, no se les puede preguntar las lecturas actuales. Lo único que tenemos disponible son los pulsos: el cierre de los contactos del relé cada litro. Las salidas de los relés están conectadas a GPIO12/GPIO13. Activaré la resistencia de pull-up por software dentro del módulo.

Inicialmente olvidé prever las resistencias R8 y R9 y en mi versión de la placa no están. Pero dado que ya estoy compartiendo el esquema para el público, vale la pena corregir este descuido. Las resistencias son necesarias para no quemar el puerto en caso de que el firmware falle y configure una alta en el pin, mientras que el relé cortocircuita esta línea a tierra (con la resistencia circulará un máximo de 3.3V/1000Ω = 3.3mA).

Es hora de pensar qué hacer si se va la electricidad. La primera opción es solicitar al servidor los valores iniciales de los medidores desde el principio. Pero esto complicaría considerablemente el protocolo de intercambio. Además, el funcionamiento del dispositivo en ese caso dependería del estado del servidor. Si después de un corte de energía el servidor no se inicia (o se inicia más tarde), el medidor de agua no podría solicitar los valores iniciales y funcionaría incorrectamente.

Por lo tanto, decidí implementar el almacenamiento de los valores de los medidores en un chip de memoria conectado por I2C. No tengo requisitos especiales sobre el tamaño de la memoria flash: solo necesito guardar 2 números (la cantidad de litros de agua caliente y fría según los medidores). Cualquier módulo pequeño sería adecuado. Sin embargo, debemos tener en cuenta la cantidad de ciclos de escritura. La mayoría de los módulos tienen 100 mil ciclos, algunos hasta un millón.

Aparentemente, un millón es mucho. Pero en mis 4 años viviendo en mi apartamento, he consumido algo más de 500 metros cúbicos de agua, ¡eso son 500 mil litros! Y 500 mil registros en la memoria flash. Y eso es solo agua fría. Claro, se podría cambiar el chip cada pocos años, pero parece que hay chips FRAM. Desde el punto de vista de la programación, son el mismo I2C EEPROM, solo que con una cantidad de ciclos de reescritura muy alta (cientos de millones). Solo que aún no tengo la oportunidad de ir a la tienda que tiene esos chips, por lo que por ahora usaré un 24LC512 convencional.

Placa de circuito impreso

Inicialmente, planeaba hacer la placa en casa. Por eso la placa se diseñó como de una sola cara. Pero después de luchar durante un maldito hora con un tóner láser y la máscara de soldadura (sin ella no es como debe ser), decidí pedir las placas a los chinos.

Conectando el contador de agua a la casa inteligente

Casi antes de hacer el pedido de la placa, me di cuenta de que, además del chip de memoria flash, se puede conectar algo más útil al bus I2C, como un display. Todavía estoy pensando qué mostrar en él, pero hay que distribuirlo en la placa. Y dado que ya tenía la intención de pedir las placas a la fábrica, no tenía sentido limitarme a una placa de una sola cara, por lo que las líneas en I2C son las únicas en la parte trasera de la placa.

Se relaciona un gran error con la disposición unilateral. Dado que la placa se diseñó de manera unidireccional, se planeaba colocar las pistas y los componentes SMD de un lado, mientras que los componentes de salida, conectores y la fuente de alimentación se colocaban del otro lado. Cuando recibí las placas un mes después, olvidé el plan original y soldé todos los componentes en el lado frontal. Y solo cuando llegó el momento de soldar la fuente de alimentación, descubrí que positivo y negativo estaban conectados al revés. Tuve que improvisar con puentes. En la imagen anterior ya cambié la disposición, pero la tierra se conecta de una parte de la placa a otra a través de los pines del botón Boot (aunque se podría haber hecho una pista en la segunda capa).

Así quedó

Conectando el contador de agua a la casa inteligente

Caja

El siguiente paso es la carcasa. Con una impresora 3D, esto no es un problema. No me complicué demasiado: simplemente dibujé una caja del tamaño necesario y hice cortes en los lugares requeridos. La tapa se fija a la carcasa con pequeños tornillos autorroscantes.

Conectando el contador de agua a la casa inteligente

Ya mencioné que el botón Boot puede ser usado como un botón de propósito general, así que lo llevaremos a la parte frontal. Para esto, dibujé un 'pozo' especial donde reside el botón.

Conectando el contador de agua a la casa inteligente

Dentro de la carcasa también hay soportes donde se coloca la placa y se fija con un solo tornillo M3 (no quedó más espacio en la placa).

Elegí la pantalla cuando imprimí el primer prototipo de la carcasa. Un display estándar de dos líneas no cabía en esta carcasa, pero encontré un display OLED SSD1306 128×32 en mis reservas. Es un poco pequeño, pero no tengo que mirarlo todos los días, así que servirá.

Pensando en cómo se tendrían que trazar los cables, decidí colocar la pantalla en el medio de la carcasa. La ergonomía, por supuesto, está por los suelos: el botón está arriba y la pantalla abajo. Pero ya dije que la idea de añadir la pantalla llegó demasiado tarde y me daba pereza rediseñar la placa para mover el botón.

Dispositivo ensamblado. El módulo de pantalla está pegado con pegamento térmico.

Conectando el contador de agua a la casa inteligente

Conectando el contador de agua a la casa inteligente

El resultado final se puede ver en KDPV.

Firmware

Pasemos a la parte de software. Para este tipo de pequeños proyectos, me gusta mucho usar el lenguaje Python (micropython)- el código resulta muy compacto y comprensible. Afortunadamente, aquí no hay necesidad de bajar al nivel de registros para optimizar hasta los microsegundos; se puede hacer todo desde Python.

Parece que todo es simple, pero no lo es tanto: el dispositivo cuenta con varias funciones independientes.

  • El usuario presiona un botón y observa la pantalla.
  • Los litros se acumulan y se actualizan los valores en la memoria flash.
  • El módulo supervisa la señal WiFi y se reconecta si es necesario.
  • Sin una luz parpadeante, no se puede hacer nada.

No se puede permitir que una función no funcione mientras que otra se retrasa por alguna razón. Ya he tenido malas experiencias en otros proyectos y ahora veo bugs como "se perdió otro litro porque en ese momento se estaba actualizando la pantalla" o "el usuario no puede hacer nada mientras el módulo se conecta al WiFi". Claro, algunas tareas se pueden hacer mediante interrupciones, pero se puede chocar con limitaciones de tiempo, profundidad de llamadas o cambios no atómicos de variables. Y el código que intenta hacer todo al mismo tiempo rápidamente se convierte en caos.

En en un proyecto más serio. Usé la clásica multitarea por desplazamiento y FreeRTOS, pero en este caso el modelo de corutinas y la biblioteca uasync resultaron ser mucho más adecuados. Además, la implementación de corutinas en Python es simplemente increíble: está hecha para que para el programador sea fácil y conveniente. Solo escribe la lógica, simplemente indica en qué lugares se puede cambiar entre los hilos.

Propongo estudiar las diferencias entre la multitarea por desplazamiento y la multitarea concurrente como opcional. Pero ahora, finalmente, pasemos al código.

#####################################
# Counter class - implements a single water counter on specified pin
#####################################
class Counter():
    debounce_ms = const(25)
    
    def __init__(self, pin_num, value_storage):
        self._value_storage = value_storage
        
        self._value = self._value_storage.read()
        self._value_changed = False

        self._pin = Pin(pin_num, Pin.IN, Pin.PULL_UP)

        loop = asyncio.get_event_loop()
        loop.create_task(self._switchcheck())  # Thread runs forever

Cada contador es manejado por una instancia de la clase Counter. Primero, se lee el valor inicial del contador desde la EEPROM (value_storage), lo que permite la recuperación tras un corte de energía.

El pin se inicializa con pull-up interno: si el interruptor está cerrado, hay cero en la línea, si está abierto, la línea se tira hacia la alimentación y el controlador lee uno.

Además, se inicia una tarea separada que realizará la consulta del pin. Cada contador iniciará su propia tarea. Aquí está su código:

    """ Consultar el pin y avanzar el valor cuando pase otro litro """
    async def _switchcheck(self):
        last_checked_pin_state = self._pin.value()  # Obtener estado inicial

        # Consultar por un cambio en el pin
        while True:
            state = self._pin.value()
            if state != last_checked_pin_state:
                # El estado ha cambiado: actuar al respecto ahora.
                last_checked_pin_state = state
                if state == 0:
                    self._another_litre_passed()

            # Ignorar cambios de estado adicionales hasta que el interruptor se estabilice
            await asyncio.sleep_ms(Counter.debounce_ms)

Un retraso de 25 ms es necesario para filtrar el rebote de contactos y al mismo tiempo regula cuán a menudo se despierta la tarea (mientras esta tarea está en reposo, otras tareas están en funcionamiento). Cada 25 ms, la función se despierta, verifica el pin y si los contactos del relé están cerrados, significa que ha pasado un litro a través del contador y esto debe ser procesado.

    def _another_litre_passed(self):
        self._value += 1
        self._value_changed = True

        self._value_storage.write(self._value)

El procesamiento del siguiente litro es trivial: simplemente se incrementa el contador. Y también sería bueno guardar el nuevo valor en la memoria flash.

Para mayor comodidad, se han previsto "accesores".

    def value(self):
        self._value_changed = False
        return self._value

    def set_value(self, value):
        self._value = value
        self._value_changed = False

Ahora aprovechemos las ventajas de Python y la biblioteca uasync y hagamos que el objeto contador sea 'waitable' (¿cómo se traduce esto al español? ¿El que se puede esperar?).

    def __await__(self):
        while not self._value_changed:
            yield from asyncio.sleep(0)

        return self.value()

    __iter__ = __await__  

Esta es una función conveniente que espera hasta que el valor del contador se actualiza: la función se despierta de vez en cuando y verifica el indicador _value_changed. La peculiaridad de esta función es que el código llamante puede quedarse en espera en la llamada a esta función y dormir hasta recibir el nuevo valor.

¿Y qué hay de las interrupciones?Sí, en este punto podrías burlarte de mí, diciendo que yo mismo hablé de interrupciones, pero en la realidad implementé un sondeo tonto del pin. De hecho, las interrupciones son lo primero que probé. En el ESP8266 se puede establecer una interrupción por flanco, e incluso escribir un controlador de esa interrupción en Python. En esta interrupción se puede actualizar el valor de la variable. Probablemente, eso sería suficiente si el contador fuera un dispositivo esclavo, es decir, uno que espera hasta que le preguntan por ese valor.

Desafortunadamente (¿o afortunadamente?) mi dispositivo está activo, debe enviar mensajes por el protocolo MQTT y registrar datos en EEPROM por sí mismo. Y aquí entran las limitaciones: en las interrupciones no se puede asignar memoria ni usar una gran pila, por lo que se puede olvidar el envío de mensajes a través de la red. Existen ventajas como micropython.schedule(), que permiten ejecutar alguna función "tan pronto como sea posible", pero surge la pregunta: "¿y de qué sirve?". De repente, estamos enviando un mensaje y se interpone una interrupción que arruina los valores de las variables. O, por ejemplo, un nuevo valor del contador llegó del servidor mientras aún no hemos registrado el antiguo. En resumen, hay que construir una sincronización o encontrar una solución alternativa.

Y de vez en cuando surge un RuntimeError: schedule stack full, y ¿quién sabe por qué?

Con la consulta explícita y uasync, en este caso, resulta más bonito y fiable.

He encapsulado el trabajo con EEPROM en una pequeña clase.

class EEPROM():
    i2c_addr = const(80)

    def __init__(self, i2c):
        self.i2c = i2c
        self.i2c_buf = bytearray(4) # Evitar la creación/destrucción del buffer en cada llamada


    def read(self, eeprom_addr):
        self.i2c.readfrom_mem_into(self.i2c_addr, eeprom_addr, self.i2c_buf, addrsize=16)
        return ustruct.unpack_from("<I", self.i2c_buf)[0]    
        
    
    def write(self, eeprom_addr, value):
        ustruct.pack_into("<I", self.i2c_buf, 0, value)
        self.i2c.writeto_mem(self.i2c_addr, eeprom_addr, self.i2c_buf, addrsize=16)

Trabajar directamente con bytes en Python es complicado, ya que son precisamente los bytes los que se escriben en la memoria. Tuve que implementar una conversión entre enteros y bytes utilizando la biblioteca ustruct.

Para no tener que pasar el objeto I2C y la dirección de la celda de memoria cada vez, encapsulé todo esto en una pequeña y conveniente clase.

class EEPROMValue():
    def __init__(self, i2c, eeprom_addr):
        self._eeprom = EEPROM(i2c)
        self._eeprom_addr = eeprom_addr
        

    def read(self):
        return self._eeprom.read(self._eeprom_addr)


    def write(self, value):
        self._eeprom.write(self._eeprom_addr, value)

El objeto I2C se crea con los siguientes parámetros.

i2c = I2C(freq=400000, scl=Pin(5), sda=Pin(4))

Llegamos a lo más interesante: la implementación de la comunicación con el servidor a través de MQTT. No es necesario implementar el protocolo en sí; en internet encontré una implementación asíncrona lista.. La usaremos.

Lo más interesante se reúne en la clase CounterMQTTClient, que se basa en la clase MQTTClient de la biblioteca. Comenzaremos con la periferia.

#####################################
# Class handles both counters and sends their status to MQTT
#####################################
class CounterMQTTClient(MQTTClient):

    blue_led = Pin(2, Pin.OUT, value = 1)
    button = Pin(0, Pin.IN)

    hot_counter = Counter(12, EEPROMValue(i2c, EEPROM_ADDR_HOT_VALUE))
    cold_counter = Counter(13, EEPROMValue(i2c, EEPROM_ADDR_COLD_VALUE))

Aquí se crean y configuran los pines de la bombilla y el botón, así como los objetos contadores de agua fría y caliente.

La inicialización no es tan trivial.

    def __init__(self):
        self.internet_outage = True
        self.internet_outages = 0
        self.internet_outage_start = ticks_ms()

        with open("config.txt") as config_file:
            config['ssid'] = config_file.readline().rstrip()
            config['wifi_pw'] = config_file.readline().rstrip()
            config['server'] = config_file.readline().rstrip()
            config['client_id'] = config_file.readline().rstrip()
            self._mqtt_cold_water_theme = config_file.readline().rstrip()
            self._mqtt_hot_water_theme = config_file.readline().rstrip()
            self._mqtt_debug_water_theme = config_file.readline().rstrip()

        config['subs_cb'] = self.mqtt_msg_handler
        config['wifi_coro'] = self.wifi_connection_handler
        config['connect_coro'] = self.mqtt_connection_handler
        config['clean'] = False
        config['clean_init'] = False
        super().__init__(config)

        loop = asyncio.get_event_loop()
        loop.create_task(self._heartbeat())
        loop.create_task(self._counter_coro(self.cold_counter, self._mqtt_cold_water_theme))
        loop.create_task(self._counter_coro(self.hot_counter, self._mqtt_hot_water_theme))
        loop.create_task(self._display_coro())

Para establecer los parámetros de funcionamiento de la biblioteca mqtt_as se utiliza un gran diccionario de diversas configuraciones — config. La mayor parte de las configuraciones por defecto nos convienen, pero muchas deben ser especificadas explícitamente. Para no escribir configuraciones directamente en el código, las almaceno en un archivo de texto config.txt. Esto permite cambiar el código independientemente de la configuración, así como crear varios dispositivos idénticos con diferentes parámetros.

El último bloque de código lanza varias corutinas para atender distintas funciones del sistema. Aquí hay una corutina que atiende los contadores.

    async def _counter_coro(self, counter, topic):
        # Publicar valor inicial
        value = counter.value()
        await self.publish(topic, str(value))

        # Publicar cada nuevo valor
        while True:
            value = await counter
            await self.publish_msg(topic, str(value))

La corutina espera un nuevo valor del contador en un bucle y, tan pronto como aparece, envía un mensaje a través del protocolo MQTT. El primer fragmento de código envía el valor inicial incluso si el agua no fluye a través del contador.

La clase base MQTTClient se autoadministra, inicia la conexión por WiFi por sí misma y se reconecta cuando la conexión desaparece. La biblioteca nos informa sobre los cambios en el estado de la conexión WiFi mediante la llamada a wifi_connection_handler.

    async def wifi_connection_handler(self, state):
        self.internet_outage = not state
        if state:
            self.dprint('WiFi está activo.')
            duration = ticks_diff(ticks_ms(), self.internet_outage_start) // 1000
            await self.publish_debug_msg('ReconectadoDespués', duration)
        else:
            self.internet_outages += 1
            self.internet_outage_start = ticks_ms()
            self.dprint('WiFi está inactivo.')
            
        await asyncio.sleep(0)

La función ha sido tomada de ejemplos. En este caso, cuenta el número de desconexiones (internet_outages) y su duración. Cuando se restablece la conexión, se envía al servidor el tiempo de inactividad.

Cabe mencionar que el último sleep solo es necesario para que la función sea asíncrona; en la biblioteca se llama a través de await, y solo pueden ser invocadas funciones que tengan otro await en su interior.

Además de conectarse a WiFi, también es necesario establecer una conexión con el broker MQTT (servidor). Esto también lo maneja la biblioteca, dejando a nuestra disposición la oportunidad de hacer algo útil una vez establecida la conexión.

    async def mqtt_connection_handler(self, client):
        await client.subscribe(self._mqtt_cold_water_theme)
        await client.subscribe(self._mqtt_hot_water_theme)

Aquí nos suscribimos a varios mensajes; el servidor ahora tiene la capacidad de establecer los valores actuales de los contadores enviando un mensaje correspondiente.

    def mqtt_msg_handler(self, topic, msg):
        topicstr = str(topic, 'utf8')
        self.dprint("Mensaje MQTT recibido topic={}, msg={}".format(topicstr, msg))

        if topicstr == self._mqtt_cold_water_theme:
            self.cold_counter.set_value(int(msg))

        if topicstr == self._mqtt_hot_water_theme:
            self.hot_counter.set_value(int(msg))

Esta función procesa los mensajes recibidos y, dependiendo del tema (nombre del mensaje), actualiza los valores de uno de los contadores.

Un par de funciones de ayuda.

    # Publish a message if WiFi and broker is up, else discard
    async def publish_msg(self, topic, msg):
        self.dprint("Publishing message on topic {}: {}".format(topic, msg))
        if not self.internet_outage:
            await self.publish(topic, msg)
        else:
            self.dprint("Message was not published - no internet connection")

Esta función se encarga de enviar un mensaje en caso de que la conexión esté establecida. Si no hay conexión, el mensaje se ignora.

Y esta es simplemente una función conveniente que genera y envía mensajes de depuración.

    async def publish_debug_msg(self, subtopic, msg):
        await self.publish_msg("{}\/{}".format(self._mqtt_debug_water_theme, subtopic), str(msg))

Tanto texto, y aún no hemos parpadeado el LED. Aquí está.

    # Blink flash LED if WiFi down
    async def _heartbeat(self):
        while True:
            if self.internet_outage:
                self.blue_led(not self.blue_led()) # Fast blinking if no connection
                await asyncio.sleep_ms(200) 
            else:
                self.blue_led(0) # Rare blinking when connected
                await asyncio.sleep_ms(50)
                self.blue_led(1)
                await asyncio.sleep_ms(5000)

He previsto dos modos de parpadeo. Si se pierde la conexión (o se está estableciendo), el dispositivo parpadeará rápidamente. Si la conexión está establecida, el dispositivo parpadea cada 5 segundos. Si es necesario, aquí se pueden implementar otros modos de parpadeo.

Pero el LED es solo un capricho. También estamos pensando en la pantalla.

    async def _display_coro(self):
        display = SSD1306_I2C(128,32, i2c)
    
        while True:
            display.poweron()
            display.fill(0)
            display.text("COLD: {:.3f}".format(self.cold_counter.value() / 1000), 16, 4)
            display.text("HOT:  {:.3f}".format(self.hot_counter.value() / 1000), 16, 20)
            display.show()
            await asyncio.sleep(3)
            display.poweroff()

            while self.button():
                await asyncio.sleep_ms(20)

Esto es lo que estaba diciendo: qué fácil y conveniente es trabajar con corutinas. Esta pequeña función describe TODA la interacción con el usuario. La corutina simplemente espera a que se presione un botón y enciende la pantalla durante 3 segundos. En la pantalla se muestran las lecturas actuales de los contadores.

Quedan un par de detalles más. Aquí está la función que reinicia todo esto. El bucle principal solo se encarga de enviar información de depuración cada minuto. En general, lo presento tal como está; no creo que necesite muchos comentarios.

   async def main(self):
        while True:
            try:
                await self._connect_to_WiFi()
                await self._run_main_loop()
                    
            except Exception as e:
                self.dprint('Fallo de comunicación global: ', e)
                await asyncio.sleep(20)

    async def _connect_to_WiFi(self):
        self.dprint('Conectándose a WiFi y MQTT')
        sta_if = network.WLAN(network.STA_IF)
        sta_if.connect(config['ssid'], config['wifi_pw'])
        
        conn = False
        while not conn:
            await self.connect()
            conn = True

        self.dprint('¡Conectado!')
        self.internet_outage = False

    async def _run_main_loop(self):
        # Bucle para siempre
        mins = 0
        while True:
            gc.collect()  # Para estadísticas de RAM.
            mem_free = gc.mem_free()
            mem_alloc = gc.mem_alloc()

            try:
                await self.publish_debug_msg("Tiempo de actividad", mins)
                await self.publish_debug_msg("Repubs", self.REPUB_COUNT)
                await self.publish_debug_msg("Cortes", self.internet_outages)
                await self.publish_debug_msg("MemLibre", mem_free)
                await self.publish_debug_msg("MemAsignada", mem_alloc)
            except Exception as e:
                self.dprint("Ocurrió una excepción: ", e)
            mins += 1

            await asyncio.sleep(60)

Bueno, un par de configuraciones y constantes más para completar la descripción.

#####################################
# Constants and configuration
#####################################


config['keepalive'] = 60
config['clean'] = False
config['will'] = ('/ESP/Wemos/Water/LastWill', 'Goodbye cruel world!', False, 0)

MQTTClient.DEBUG = True

EEPROM_ADDR_HOT_VALUE = const(0)
EEPROM_ADDR_COLD_VALUE = const(4)

Así se inicia todo esto.

client = CounterMQTTClient()
loop = asyncio.get_event_loop()
loop.run_until_complete(client.main())

Algo le ha pasado a mi memoria.

Así que todo el código está aquí. Subí los archivos usando la utilidad ampy, que permite cargarlos en la memoria interna (la que está en el propio ESP-07) y luego acceder a ellos desde el programa como archivos normales. También subí las bibliotecas que utilizo: mqtt_as, uasyncio, ssd1306 y collections (utilizada dentro de mqtt_as).

Lo iniciamos y... recibiendo MemoryError. Cuanto más intentaba entender dónde se estaba filtrando la memoria, más pronto aparecía este error. Una rápida búsqueda en Google me llevó a entender que el microcontrolador tiene en total 30kb de memoria, mientras que 65kb de código (incluyendo bibliotecas) no caben en absoluto.

Pero hay una solución. Resulta que micropython no ejecuta el código directamente desde el archivo .py; primero, este archivo se compila. Y lo hace directamente en el microcontrolador, convirtiéndose en código de bytes que luego se almacena en la memoria. Además, el compilador también necesita cierto volumen de RAM.

El truco consiste en liberar al microcontrolador de la costosa compilación. Se pueden compilar los archivos en una computadora grande, y luego cargar el código de bytes ya listo en el microcontrolador. Para esto, es necesario descargar el firmware de micropython y compilar la utilidad mpy-cross.

No escribí un Makefile, sino que manualmente pasé y compilé todos los archivos necesarios (incluidas las bibliotecas) más o menos así

mpy-cross water_counter.py

Solo queda cargar los archivos con la extensión .mpy, recordando eliminar previamente los .py correspondientes del sistema de archivos del dispositivo.

Desarrollé todo en el programa (¿IDE?) ESPlorer. Permite cargar scripts en el microcontrolador y ejecutarlos de inmediato. En mi caso, toda la lógica y la creación de todos los objetos se encuentran en el archivo water_counter.py (.mpy). Pero para que todo esto se inicie automáticamente al arrancar, también debe haber un archivo llamado main.py. Y debe ser precisamente .py, no un .mpy precompilado. Aquí está su contenido trivial

import water_counter

Ejecutamos y todo funciona. Pero la memoria libre es alarmantemente baja — alrededor de 1 kB. Aún tengo planes para ampliar la funcionalidad del dispositivo, y claramente ese kilobyte será insuficiente. Pero resulta que hay una solución incluso para este caso.

La cuestión es la siguiente. Incluso cuando los archivos están compilados en código de bytes y se encuentran en el sistema de archivos interno, en realidad todavía se cargan en la RAM y se ejecutan desde allí. Pero resulta que micropython puede ejecutar código de bytes directamente desde la memoria flash, pero para ello debe integrarse directamente en el firmware. No es complicado, aunque en mi netbook me llevó bastante tiempo (solo allí estaba usando Linux).

El algoritmo es el siguiente:

  • Descargar e instalar ESP Open SDK. Esta herramienta compila el compilador y las bibliotecas para programas para el ESP8266. Se compila siguiendo las instrucciones en la página principal del proyecto (yo elegí la instalación STANDALONE=yes)
  • Descargar código fuente de micropython
  • Las bibliotecas necesarias deben colocarse en ports/esp8266/modules dentro del árbol de micropython
  • Compilamos el firmware según las instrucciones en el archivo ports/esp8266/README.md
  • Cargamos el firmware en el microcontrolador (yo lo hago en Windows con programas como ESP8266Flasher o el esptool de Python)

Listo, ahora 'import ssd1306' levantará el código directamente desde el firmware y la memoria RAM no se desperdiciará en esto. Con este truco, he cargado en el firmware solo el código de las bibliotecas, mientras que el código principal del programa se ejecuta desde el sistema de archivos. Esto permite modificar fácilmente el programa sin recompilar el firmware. En este momento, tengo aproximadamente 8.5 kB de RAM libres. Esto permitirá implementar bastante funcionalidad útil en el futuro. Y si la memoria se queda muy corta, se puede meter también el programa principal en el firmware.

¿Y ahora qué hacer con esto?

Ok, el hardware está soldado, el firmware está escrito, la caja está impresa, el dispositivo está pegado a la pared y parpadea felizmente con una luz. Pero por ahora, esto sigue siendo una caja negra (en el sentido literal y figurado) y aún tiene poco sentido. Es hora de hacer algo con los mensajes MQTT que se envían al servidor.

Mi "hogar inteligente" funciona con el sistema Majordomo. El módulo MQTT está disponible de serie o se instala fácilmente desde el mercado de complementos; ya no recuerdo de dónde lo tengo. MQTT no es autosuficiente: se necesita un llamado broker, un servidor que recibe, clasifica y redirige los mensajes MQTT a los clientes. Yo uso mosquitto, que (al igual que Majordomo) también se ejecuta en la misma netbook.

Después de que el dispositivo envíe un mensaje al menos una vez, el valor aparecerá de inmediato en la lista.

Conectando el contador de agua a la casa inteligente

Estos valores ahora se pueden vincular a los objetos del sistema, se pueden utilizar en escenarios de automatización y someter a diversos análisis; todo esto está fuera del alcance de este artículo. Para quienes estén interesados en el sistema Majordomo, puedo recomendar el canal Electrónica En El Objetivo — un amigo también está construyendo un hogar inteligente y explica claramente cómo configurar el sistema.

Solo mostraré un par de gráficos. Este es un gráfico simple de valores por día

Conectando el contador de agua a la casa inteligente
Se puede ver que por la noche casi nadie utilizó agua. En un par de ocasiones alguien fue al baño y, parece que el filtro de ósmosis inversa succiona un par de litros por la noche. Por la mañana, el consumo aumenta significativamente. Normalmente uso agua del calentador, pero aquí quise tomar un baño y temporalmente cambié a agua caliente de la ciudad; esto también se refleja claramente en el gráfico inferior.

De este gráfico aprendí que ir al baño consume 6-7 litros de agua, tomar una ducha entre 20-30 litros, lavar los platos alrededor de 20 litros, y para tomar un baño se necesitan 160 litros. Durante el día, mi familia consume alrededor de 500-600 litros.

Para los más curiosos, se puede consultar los registros de cada valor individual.

Conectando el contador de agua a la casa inteligente

De aquí aprendí que con el grifo abierto, el agua fluye a una velocidad de aproximadamente 1 litro cada 5 segundos.

Pero en esta forma, la estadística no es muy fácil de ver. En majordomo hay más opciones para visualizar gráficos de consumo por días, semanas y meses. Por ejemplo, aquí está el gráfico de consumo en barras.

Conectando el contador de agua a la casa inteligente

Por ahora, solo tengo datos de una semana. En un mes, este gráfico será más representativo: cada día corresponderá a una barra separada. Las correcciones de valores que ingreso manualmente (la barra más grande) distorsionan un poco la imagen. Y aún no está claro si establecí incorrectamente los primeros valores casi un metro menos, o si es un error en el firmware y no todos los litros se contabilizaron. Necesito más tiempo.

Aún necesito trabajar un poco en los gráficos, mejorarlos y adornarlos. También puedo tener la intención de construir un gráfico de consumo de memoria con fines de depuración: tal vez ahí haya alguna fuga. Quizás de alguna manera muestre los períodos en los que no hubo internet. Por ahora, todo esto está en la fase de idea.

Conclusión

Hoy, mi apartamento se volvió un poco más inteligente. Con este pequeño dispositivo, será más fácil para mí seguir el consumo de agua en casa. Si antes me quejaba de 'otra vez hemos consumido mucha agua este mes', ahora podré encontrar la fuente de ese consumo.

Alguien podría considerar extraño mirar las lecturas en la pantalla si está a un metro del propio contador. Pero en un futuro no muy lejano, planeo mudarme a otro apartamento donde habrá varios conductos de agua, y es probable que los contadores estén ubicados en la plataforma de escaleras. Así que un dispositivo de lectura remota será muy útil.

También planeo ampliar la funcionalidad del dispositivo. Ya estoy considerando válvulas motorizadas. Ahora, para cambiar entre agua de la caldera y agua de la ciudad, necesito girar 3 grifos en un lugar de difícil acceso. Sería mucho más conveniente hacerlo con un solo botón y la correspondiente indicación. Y, por supuesto, valdría la pena implementar protección contra fugas.

En este artículo, comparto mi versión de un dispositivo basado en ESP8266. En mi opinión, he creado una opción de firmware bastante interesante en micropython utilizando corutinas: simple y bonito. He intentado describir muchos matices y errores con los que me encontré en el camino. Puede que haya detallado demasiado, pero personalmente prefiero saltar partes innecesarias que tener que deducir lo que no se dijo.

Como siempre, estoy abierto a críticas constructivas.

Código fuente
Esquema y placa
Modelo de carcasa

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster