Conectando o medidor de água à casa inteligente

Antigamente, os sistemas de automação residencial, ou como são frequentemente chamados, "casa inteligente", eram extremamente caros e apenas os ricos podiam se dar ao luxo de tê-los. Hoje, no mercado, é possível encontrar kits acessíveis com sensores, botões / interruptores e dispositivos executores para gerenciar iluminação, tomadas, ventilação, abastecimento de água e outros consumidores. E até mesmo o mais desajeitado dos DIYers pode se envolver nessa beleza e montar dispositivos para a casa inteligente sem gastar muito.

Conectando o medidor de água à casa inteligente

Normalmente, os dispositivos oferecidos são sensores ou mecanismos executores. Eles permitem implementar facilmente cenários como "ao ser acionado o sensor de movimento, a luz liga" ou "o interruptor perto da saída apaga a luz em todo o apartamento". Mas, com a telemetria, as coisas não foram tão bem. Na melhor das hipóteses, é um gráfico de temperatura e umidade ou o consumo instantâneo em uma tomada específica.

Recentemente, instalei contadores de água com saída por pulso. A cada litro que passa pelo contador, um reed switch é acionado e fecha o contato. Agora, só falta conectar os fios e tentar tirar proveito disso. Por exemplo, analisar o consumo de água por horas e dias da semana. E se houver vários ramais de água no apartamento, é mais prático ver todos os indicadores atuais em uma única tela, do que ter que revirar lugares de difícil acesso com uma lanterna.

Abaixo, meu dispositivo baseado em ESP8266, que conta os pulsos dos contadores de água e envia as leituras para o servidor da casa inteligente via MQTT. Vamos programar em micropython usando a biblioteca uasyncio. Ao criar o firmware, encontrei algumas dificuldades interessantes, das quais também falarei neste artigo. Vamos lá!

Esquema

Conectando o medidor de água à casa inteligente

O coração de todo o esquema é um módulo com o microcontrolador ESP8266. Inicialmente, planejei usar o ESP-12, mas o que eu tinha estava com defeito. Tive que me contentar com o módulo ESP-07, que estava disponível. Felizmente, eles são iguais em pinos e funcionalidade, a diferença está apenas na antena — o ESP-12 tem a antena embutida, enquanto o ESP-07 possui uma antena externa. No entanto, mesmo sem antena, o sinal WiFi na minha casa de banho é captado normalmente.

A fiação do módulo é padrão:

  • botão de reset com pull-up e capacitor (embora ambos já estejam dentro do módulo)
  • O sinal enable (CH_PD) está conectado à alimentação
  • O GPIO15 é puxado para o terra. Isso é necessário apenas na inicialização, mas de qualquer forma não tenho mais nada para conectar a esse pino.

Para colocar o módulo em modo de programação, é necessário conectar o GPIO2 ao terra, e para facilitar, eu providenciei um botão de Boot. Em estado normal, este pino é puxado para a alimentação.

O estado da linha GPIO2 é verificado apenas no início da operação — ao fornecer alimentação ou logo após um reset. Assim, o módulo pode inicializar normalmente ou entrar em modo de programação. Após a inicialização, este pino pode ser usado como um GPIO comum. E já que temos um botão, podemos atribuir a ele alguma função útil.

Para programação e depuração, vou usar o UART que foi conectado ao cabeçote. Quando necessário, é só conectar um adaptador USB-UART. Só não posso esquecer que o módulo é alimentado por 3.3V. Se esquecer de mudar o adaptador para essa voltagem e fornecer 5V, o módulo provavelmente queimará.

Com eletricidade no banheiro eu não tenho problemas — a tomada está localizada a cerca de um metro dos medidores, então vou alimentar a partir de 220V. Como fonte de alimentação, vou usar um pequeno bloco HLK-PM03 da Tenstar Robot. Pessoalmente, tenho dificuldades com eletrônica analógica e de potência, e aqui está uma fonte de alimentação pronta em um pequeno gabinete.

Para sinalizar os modos de operação, eu providenciei um LED conectado ao GPIO2. No entanto, eu não fiz a soldagem, pois o módulo ESP-07 já tem um LED, que está conectado ao mesmo GPIO2. Mas na placa, que fique — quem sabe eu queira levar esse LED para o exterior.

Vamos para a parte mais interessante. Os medidores de água não têm nenhuma lógica, não é possível perguntar por leituras atuais. O único dado disponível são os impulsos — cada litro provoca o fechamento dos contatos do relé. As saídas do relé estão conectadas ao GPIO12/GPIO13. O resistor de pull-up eu vou ativar programaticamente dentro do módulo.

Inicialmente, eu me esqueci de incluir os resistores R8 e R9 e na minha versão da placa eles não estão presentes. Mas como estou divulgando o esquema publicamente, vale a pena corrigir essa falha. Os resistores são necessários para não queimar a porta caso o firmware falhe e coloque um nível alto no pino, enquanto o relé fecha essa linha ao terra (com o resistor, a corrente máxima será 3.3V/1000Ω = 3.3mA).

É hora de pensar no que fazer se faltar eletricidade. A primeira opção é solicitar ao servidor os valores iniciais dos contadores desde o início. Porém, isso exigiria uma complicação significativa no protocolo de comunicação. Além disso, a funcionalidade do dispositivo nesse caso depende do estado do servidor. Se o servidor não ligasse após a queda de energia (ou se ligasse mais tarde), o contador de água não conseguiria solicitar os valores iniciais e funcionaria de maneira incorreta.

Portanto, decidi implementar o armazenamento dos valores dos contadores em um chip de memória conectado via I2C. Não tenho requisitos especiais quanto ao tamanho da memória flash – preciso apenas armazenar 2 números (a quantidade de litros dos contadores de água quente e fria). Qualquer módulo pequeno servirá. Mas é importante observar o número de ciclos de gravação. A maioria dos módulos tem 100 mil ciclos, mas alguns podem chegar a um milhão.

À primeira vista, um milhão parece muito. Mas, durante meus 4 anos morando no meu apartamento, consumi um pouco mais de 500 metros cúbicos de água, ou seja, 500 mil litros! E 500 mil gravações na flash. E isso é apenas água fria. Claro, poderia trocar o chip a cada poucos anos, mas descobri que existem chips FRAM. Do ponto de vista da programação, é o mesmo EEPROM I2C, mas com um número muito maior de ciclos de regravação (centenas de milhões). Só que ainda não consegui ir à loja que vende esses chips, então por enquanto vou usar um 24LC512 comum.

Placa de circuito impresso

Inicialmente, eu planejava fazer a placa em casa. Portanto, a placa foi projetada como unidirecional. Mas depois de ter passado uma hora inteira lutando com um ferro de passar a laser e uma máscara de solda (sem ela, não fica como deveria), decidi encomendar as placas dos chineses.

Conectando o medidor de água à casa inteligente

Quase antes de fazer o pedido da placa, percebi que além do chip de memória flash, poderia conectar algo mais útil no barramento I2C, como um display. O que exatamente mostrar nele ainda é uma questão, mas preciso planejar isso na placa. Como decidí encomendá-las na fábrica, não fazia sentido limitar-me a uma placa unidirecional, então as linhas do I2C são as únicas na parte de trás da placa.

Com a fiação unidirecional também havia um grande problema. Como a placa foi projetada para ser unidirecional, as trilhas e os componentes SMD deveriam ser colocados de um lado, enquanto os componentes de saída, conectores e a fonte de alimentação do outro. Quando recebi as placas um mês depois, eu me esqueçi do plano original e soldei todos os componentes na frente. E só quando cheguei à parte de soldar a fonte de alimentação percebi que o positivo e o negativo estavam invertidos. Tive que improvisar com jumpers. Na imagem acima, já alterei a fiação, mas o aterramento passa de uma parte da placa para a outra através dos terminais do botão Boot (embora pudesse ter feito uma trilha na segunda camada).

Ficou assim

Conectando o medidor de água à casa inteligente

Gabinete

O próximo passo é o gabinete. Com uma impressora 3D isso não é um problema. Não me preocupou muito — apenas desenhei uma caixa do tamanho certo e fiz cortes nos lugares necessários. A tampa é fixada ao gabinete com pequenos parafusos.

Conectando o medidor de água à casa inteligente

Já mencionei que o botão Boot pode ser usado como um botão de propósito geral — então vamos levá-lo para o painel frontal. Para isso, desenhei um “poço” especial onde o botão fica.

Conectando o medidor de água à casa inteligente

Dentro do gabinete também há suportes onde a placa é instalada e fixada com um único parafuso M3 (não havia mais espaço na placa).

Escolhi o display já quando imprimi a primeira versão do gabinete. Um display padrão de duas linhas não cabia neste gabinete, mas encontrei um display OLED SSD1306 128×32 nos meus materiais. Um pouco pequeno, mas não preciso olhar para ele todos os dias — serve.

Ao supor como seriam os fios, decidi colar o display no meio do gabinete. A ergonomia, claro, é péssima — o botão em cima, o display embaixo. Mas já disse que a ideia de colocar o display surgiu tarde demais e eu estava com preguiça de refazer a placa para deslocar o botão.

Dispositivo montado. O módulo do display está colado com cola quente.

Conectando o medidor de água à casa inteligente

Conectando o medidor de água à casa inteligente

O resultado final pode ser visto na próxima imagem.

Firmware

Vamos para a parte de programação. Para essas pequenas invenções, gosto muito de usar a linguagem Python (micropython)- o código acaba sendo muito compacto e compreensível. Além disso, não há necessidade de descer ao nível dos registradores para otimizar microssegundos — tudo pode ser feito a partir do Python.

Parece simples, mas não é bem assim — o dispositivo tem várias funções independentes em andamento:

  • O usuário pressiona o botão e observa o display
  • Os litros são contabilizados e atualizam os valores na memória flash
  • O módulo monitora o sinal WiFi e reconecta-se se necessário
  • E sem uma luz piscante, nada disso é possível

Não se pode permitir que uma função falhe enquanto outra, por qualquer motivo, está travada. Já passei por situações complicadas em outros projetos e agora consigo ver falhas como 'perdemos um litro porque o display estava sendo atualizado' ou 'o usuário não consegue fazer nada enquanto o módulo se conecta ao WiFi'. Certamente, algumas coisas podem ser feitas por meio de interrupções, mas você pode esbarrar em limitações relacionadas à duração, profundidade das chamadas ou à alteração não atômica de variáveis. E o código que tenta fazer tudo de uma vez rapidamente se transforma em uma bagunça.

Em projetos mais sérios eu usei a clássica multitarefa por preempção e o FreeRTOS, mas neste caso o modelo de corrotinas e a biblioteca uasync foi muito mais adequada . E a implementação de corrotinas em Python é simplesmente fantástica — para o programador, tudo é fácil e conveniente. Basta escrever a lógica, só indicando onde é possível alternar entre as threads.

Recomendo estudar as diferenças entre multitarefa por preempção e concorrente como um aprofundamento. Mas agora, finalmente, vamos ao 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 é tratado por uma instância da classe Counter. Primeiro, o valor inicial do contador é lido da EEPROM (value_storage) — isso implementa a recuperação após a perda de energia.

O pino é inicializado com um pull-up interno: se o sensor reed estiver fechado — a linha terá valor zero, se estiver aberto, a linha irá para a alimentação e o controlador lê um.

Além disso, uma tarefa separada é iniciada para realizar a leitura do pino. Cada contador iniciará sua própria tarefa. Aqui está o código dela

    """ Verifica o pino e avança o valor quando mais um litro passar """
    async def _switchcheck(self):
        last_checked_pin_state = self._pin.value()  # Obtém o estado inicial

        # Poll para uma mudança no pino
        while True:
            state = self._pin.value()
            if state != last_checked_pin_state:
                # O estado mudou: aja de acordo agora.
                last_checked_pin_state = state
                if state == 0:
                    self._another_litre_passed()

            # Ignora mudanças de estados até que o interruptor se estabilize
            await asyncio.sleep_ms(Counter.debounce_ms)

Um atraso de 25 ms é necessário para filtrar o ruído dos contatos, além de regular com que frequência a tarefa desperta (enquanto essa tarefa está em espera, outras tarefas estão em execução). A cada 25 ms, a função desperta, verifica o pino e se os contatos do relé estiverem fechados, isso significa que um litro passou pelo contador e precisa ser processado.

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

        self._value_storage.write(self._value)

O processamento de mais um litro é trivial — apenas incrementa o contador. E seria bom gravar o novo valor em um dispositivo flash.

Para facilitar o uso, foram previstos "acessores".

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

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

Agora vamos usar as maravilhas do Python e da biblioteca uasync e tornar o objeto contador esperável (como seria isso em português? Aquele que pode ser aguardado?)

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

        return self.value()

    __iter__ = __await__  

Esta é uma função conveniente que espera até que o valor do contador seja atualizado — a função desperta de tempos em tempos e verifica a flag _value_changed. O interessante dessa função é que o código que a chama pode dormir na chamada dessa função e ficar em espera até receber um novo valor.

E quanto às interrupções?Sim, nesse ponto você pode me zombar, dizendo que eu mesmo falei sobre interrupções, mas na verdade organizei uma simples sondagem do pino. Na verdade, as interrupções foram a primeira coisa que eu tentei. No ESP8266, é possível organizar uma interrupção em um nível de subida e até escrever um manipulador para essa interrupção em Python. Nessa interrupção, é possível atualizar o valor da variável. Provavelmente, isso seria suficiente se o contador fosse um dispositivo escravo — aquele que espera até que lhe perguntem o valor.

Infelizmente (ou felizmente?) meu dispositivo é ativo, ele deve enviar mensagens por conta própria usando o protocolo MQTT e gravar dados na EEPROM. E aqui entram as restrições — não é possível alocar memória e usar uma pilha grande dentro de interrupções, portanto, podemos esquecer o envio de mensagens pela rede. Existem recursos como micropython.schedule(), que permitem iniciar uma função “assim que puder”, mas surge a pergunta “qual é o sentido?”. De repente, estamos enviando alguma mensagem, e surge uma interrupção que altera os valores das variáveis. Ou, por exemplo, o servidor enviou um novo valor do contador enquanto ainda não escrevemos o antigo. Enfim, é necessário implementar uma sincronização ou encontrar outra solução.

E de vez em quando, ocorre um RuntimeError: schedule stack full e quem sabe o porquê?

Com a consulta explícita e uasync, neste caso fica de alguma forma mais bonito e confiável.

Separei o trabalho com a EEPROM em uma classe pequena.

class EEPROM():
    i2c_addr = const(80)

    def __init__(self, i2c):
        self.i2c = i2c
        self.i2c_buf = bytearray(4) # Evitar a criação/destruição do buffer a cada chamada


    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)

Trabalhar diretamente com bytes em Python é complicado, já que são os bytes que são gravados na memória. Tive que implementar uma conversão entre inteiros e bytes usando a biblioteca ustruct.

Para não precisar passar o objeto I2C e o endereço da célula de memória a cada vez, eu encapsulei tudo isso em uma classe pequena e conveniente.

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)

O próprio objeto I2C é criado com os seguintes parâmetros.

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

Chegamos à parte mais interessante — a implementação da comunicação com o servidor via MQTT. Não é necessário implementar o próprio protocolo — encontrei uma implementação assíncrona pronta. É essa que vamos usar.

Tudo de mais interessante está reunido na classe CounterMQTTClient, que é baseada na biblioteca MQTTClient. Vamos começar pela 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))

Aqui são criados e configurados os pinos da lâmpada e do botão, assim como os objetos contadores de água fria e quente.

A inicialização não é tão 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 definir os parâmetros de operação da biblioteca mqtt_as, usamos um grande dicionário de várias configurações — config. A maior parte das configurações padrão é adequada, mas muitas devem ser definidas explicitamente. Para não codificá-las diretamente, eu as armazeno em um arquivo de texto config.txt. Isso permite alterar o código independentemente das configurações e também fabricar vários dispositivos idênticos com parâmetros diferentes.

O último bloco de código inicia várias corrotinas para gerenciar diferentes funções do sistema. Aqui está, por exemplo, uma corrotina que gerencia os contadores.

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

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

A corrotina espera em um loop por um novo valor do contador e, assim que ele é recebido, envia uma mensagem via protocolo MQTT. A primeira parte do código publica o valor inicial mesmo que a água não esteja fluindo pelo contador.

A classe base MQTTClient se gerencia, inicia a conexão via WiFi e reconecta-se quando a conexão cai. Quando há mudanças no estado da conexão WiFi, a biblioteca nos informa chamando wifi_connection_handler.

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

A função foi adaptada a partir de exemplos. Neste caso, ela conta o número de interrupções (internet_outages) e sua duração. Ao restabelecer a conexão, o tempo de inatividade é enviado para o servidor.

Aliás, o último sleep é necessário apenas para que a função se torne assíncrona — na biblioteca, ela é chamada através de await, e só funções que possuem outro await podem ser chamadas assim.

Além da conexão com o WiFi, é necessário estabelecer uma conexão com o broker MQTT (servidor). Isso também é tratado pela biblioteca, e nos cabe fazer algo útil quando a conexão estiver estabelecida.

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

Aqui nos inscrevemos em várias mensagens — o servidor agora pode enviar os valores atuais dos contadores ao enviar a mensagem correspondente.

    def mqtt_msg_handler(self, topic, msg):
        topicstr = str(topic, 'utf8')
        self.dprint("Mensagem MQTT recebida: tópico={}, 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 função processa as mensagens recebidas e, dependendo do tema (nome da mensagem), atualiza os valores de um dos contadores.

Um par de funções auxiliares.

    # 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 função cuida de enviar uma mensagem caso a conexão esteja estabelecida. Se não houver conexão, a mensagem é ignorada.

E esta é apenas uma função conveniente que forma e envia mensagens de depuração.

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

Tanto texto, e ainda não piscamos o LED. Aqui 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)

Previ 2 modos de piscar. Se a conexão falhar (ou estiver apenas sendo estabelecida), o dispositivo piscará rapidamente. Se a conexão estiver estabelecida — o dispositivo pisca a cada 5 segundos. Se necessário, outros modos de piscar podem ser implementados aqui.

Mas o LED é apenas um brinquedo. Estamos também pensando no display.

    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)

É isso que eu quis dizer — como é simples e conveniente trabalhar com corrotinas. Esta pequena função descreve TODA a interação com o usuário. A corrotina apenas aguarda o pressionar de um botão e liga o display por 3 segundos. No display, são exibidas as leituras atuais dos contadores.

Ainda faltam alguns detalhes. Aqui está a função que reinicia tudo isso. O loop principal apenas envia diferentes informações de depuração uma vez por minuto. Em suma, apresento como está — não acho que precise de comentários.

   async def main(self):
        while True:
            try:
                await self._connect_to_WiFi()
                await self._run_main_loop()
                    
            except Exception as e:
                self.dprint('Falha na comunicação global: ', e)
                await asyncio.sleep(20)

    async def _connect_to_WiFi(self):
        self.dprint('Conectando ao WiFi e 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):
        # Loop para sempre
        mins = 0
        while True:
            gc.collect()  # Para estatísticas de RAM.
            mem_free = gc.mem_free()
            mem_alloc = gc.mem_alloc()

            try:
                await self.publish_debug_msg("Uptime", mins)
                await self.publish_debug_msg("Repubs", self.REPUB_COUNT)
                await self.publish_debug_msg("Outages", self.internet_outages)
                await self.publish_debug_msg("MemFree", mem_free)
                await self.publish_debug_msg("MemAlloc", mem_alloc)
            except Exception as e:
                self.dprint("Ocorreu uma exceção: ", e)
            mins += 1

            await asyncio.sleep(60)

Mais algumas configurações e constantes para completar a descrição.

#####################################
# 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)

Tudo isso é iniciado assim:

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

Algo aconteceu com a minha memória.

Então, todo o código está aqui. Eu carreguei os arquivos usando a ferramenta ampy — ela permite que eu os carregue na memória interna (a que está no próprio ESP-07) e depois acessá-los no programa como arquivos normais. Também carreguei as bibliotecas que uso: mqtt_as, uasyncio, ssd1306 e collections (usadas dentro do mqtt_as).

Executamos e… recebemos um MemoryError. E quanto mais eu tentava entender onde a memória estava vazando, mais cedo essa erro ocorria. Uma rápida pesquisa no Google me levou a entender que o microcontrolador tem apenas 30KB de memória, na qual 65KB de código (incluindo bibliotecas) definitivamente não cabem.

Mas há uma saída. Descobri que o micropython não executa código diretamente de um arquivo .py — esse arquivo é primeiro compilado. E ele é compilado diretamente no microcontrolador, transformando-se em bytecode, que depois é armazenado na memória. E para o funcionamento do compilador, também é necessária uma certa quantidade de RAM.

O truque é livrar o microcontrolador da compilação que consome muitos recursos. É possível compilar os arquivos em um computador grande e, em seguida, carregar o bytecode já pronto no microcontrolador. Para isso, é necessário baixar o firmware micropython e compilar a utilidade mpy-cross.

Eu não escrevi um Makefile, mas passei manualmente e compilei todos os arquivos necessários (incluindo bibliotecas) mais ou menos assim

mpy-cross water_counter.py

Agora só falta carregar os arquivos com a extensão .mpy, lembrando de remover previamente os correspondentes .py do sistema de arquivos do dispositivo.

Todo o desenvolvimento foi feito no programa (IDE?) ESPlorer. Ele permite carregar scripts no microcontrolador e executá-los imediatamente. No meu caso, toda a lógica e a criação de todos os objetos estão no arquivo water_counter.py (.mpy). Mas para que tudo isso inicie automaticamente na inicialização, deve haver também um arquivo chamado main.py. E deve ser exatamente .py, e não o .mpy pré-compilado. Aqui está o seu conteúdo trivial

import water_counter

Estamos prontos — tudo funciona. Mas a memória livre é alarmantemente pouca — cerca de 1kb. Eu ainda tenho planos para expandir a funcionalidade do dispositivo, e esse kilobyte vai ser claramente insuficiente. Mas existe uma solução para esse caso também.

O fato é que mesmo com os arquivos compilados em bytecode e armazenados no sistema de arquivos interno, na prática eles ainda são carregados na memória RAM e executados a partir de lá. Mas parece que o micropython consegue executar bytecode diretamente da memória flash, mas para isso é necessário incorporá-lo diretamente no firmware. Não é complicado, embora no meu netbook isso tenha levado um tempo considerável (só lá que eu tinha Linux).

O algoritmo é o seguinte:

  • 🥇Geary 3.36 — cliente de e-mail para o ambiente GNOME | ProHoster ESP Open SDK. Essa ferramenta compila o compilador e as bibliotecas para programas para ESP8266. Patentei seguindo as instruções na página principal do projeto (eu escolhi a instalação STANDALONE=yes)
  • Baixar códigos fonte do micropython
  • As bibliotecas necessárias devem ser colocadas em ports/esp8266/modules dentro da árvore do micropython
  • Compilamos o firmware de acordo com as instruções no arquivo ports/esp8266/README.md
  • Carregando o firmware no microcontrolador (faço isso no Windows usando os programas ESP8266Flasher ou o esptool em Python)

Pronto, agora o ‘import ssd1306’ vai carregar o código diretamente do firmware e a memória RAM não será consumida por isso. Com esse truque, coloquei no firmware apenas o código das bibliotecas, enquanto o código principal do programa é executado a partir do sistema de arquivos. Isso permite modificar facilmente o programa sem recompilar o firmware. No momento, tenho cerca de 8,5 kB de RAM livre. Isso permitirá implementar ainda muitas funcionalidades úteis no futuro. E se a memória não for suficiente, posso também colocar o programa principal no firmware.

E agora, o que fazer com isso?

Ok, o hardware está montado, o firmware escrito, a caixa impressa, o dispositivo fixado na parede e piscando alegremente. Mas por enquanto, tudo isso é uma caixa-preta (literal e figurativamente) e ainda não tem muito uso. É hora de fazer algo com as mensagens MQTT que estão sendo enviadas para o servidor.

Meu 'lar inteligente' funciona na plataforma Majordomo. O módulo MQTT já vem integrado ou é fácil de instalar do mercado de complementos — já não me lembro de onde peguei. MQTT não é autossuficiente — é necessário um chamado broker — um servidor que recebe, classifica e redireciona as mensagens MQTT para os clientes. Eu uso o mosquitto, que (assim como o majordomo) roda tudo no mesmo netbook.

Depois que o dispositivo enviar uma mensagem, o valor aparecerá imediatamente na lista.

Conectando o medidor de água à casa inteligente

Esses valores agora podem ser vinculados a objetos do sistema, podem ser usados em cenários de automação e submetidos a diferentes análises — tudo isso está fora do escopo deste artigo. Para quem estiver interessado no sistema majordomo, posso recomendar o canal Eletrônica Em Foco — um amigo também constrói uma casa inteligente e explica detalhadamente como configurar o sistema.

Vou mostrar apenas alguns gráficos. Este é um gráfico simples de valores ao longo de um dia

Conectando o medidor de água à casa inteligente
É visível que à noite quase ninguém usou água. Algumas vezes alguém foi ao banheiro e parece que o filtro de osmose reversa absorve alguns litros durante a noite. De manhã, o consumo aumenta significativamente. Normalmente uso água do aquecedor, mas aqui decidi tomar um banho e temporariamente mudei para a água quente da cidade — isso também é bem visível no gráfico inferior.

A partir deste gráfico, descobri que ir ao banheiro consome 6-7 litros de água, tomar banho consome 20-30 litros, lavar louça cerca de 20 litros, e para tomar um banho de banheira são necessários 160 litros. Ao todo, minha família consome cerca de 500-600 litros por dia.

Para os mais curiosos, é possível acessar os registros de cada valor individual.

Conectando o medidor de água à casa inteligente

Descobri que com a torneira aberta, a água flui a uma taxa de aproximadamente 1 litro a cada 5 segundos.

Mas, nesse formato, a estatística talvez não seja muito fácil de visualizar. No majordomo, existem ainda opções para visualizar gráficos de consumo por dias, semanas e meses. Aqui está, por exemplo, um gráfico de consumo em colunas.

Conectando o medidor de água à casa inteligente

Por enquanto, eu só tenho dados de uma semana. Daqui a um mês, esse gráfico será mais representativo — cada dia terá sua própria coluna. Um pouco da imagem é estragada pelos ajustes de valores que digito manualmente (a coluna mais alta). E ainda não está claro se eu defini os primeiros valores quase um metro a menos, ou se é um bug no firmware e nem todos os litros foram contabilizados. Preciso de mais tempo.

Ainda preciso ajustar os gráficos, clarear e colorir. Talvez eu também construa um gráfico de consumo de memória para fins de depuração — talvez haja algum vazamento. Talvez eu encontre uma maneira de exibir os períodos em que a internet ficou fora. Por enquanto, tudo isso está apenas no nível da ideia.

Conclusão

Hoje, meu apartamento ficou um pouco mais inteligente. Com esse pequeno dispositivo, será mais fácil monitorar o consumo de água em casa. Se antes eu reclamava ‘de novo, consumimos muita água neste mês’, agora poderei encontrar a fonte desse consumo.

Para alguns, pode parecer estranho ver as medições na tela, se estiver a um metro do próprio medidor. Mas, em um futuro não muito distante, planejo me mudar para outro apartamento, onde haverá várias colunas de água, e os medidores provavelmente estarão localizados no hall da escada. Portanto, um dispositivo para leitura remota será bastante útil.

Também pretendo expandir a funcionalidade do dispositivo. Já estou considerando válvulas motorizadas. Agora, para alternar entre água da caldeira e água da cidade, preciso girar 3 torneiras em um espaço de difícil acesso. Seria muito mais conveniente fazer isso com um único botão, com a indicação correspondente. E, naturalmente, vale a pena implementar proteção contra vazamentos.

Neste artigo, compartilhei minha versão de um dispositivo baseado no ESP8266. Na minha opinião, consegui criar uma firmware bastante interessante em Micropython, utilizando corrotinas - simples e atraente. Tentei descrever vários detalhes e problemas com os quais me deparei no processo. Talvez eu tenha sido muito detalhista, pessoalmente, prefiro passar rapidamente por informações excessivas do que ter que adivinhar o que não foi dito.

Como sempre, estou aberto a críticas construtivas.

Código fonte
Esquema e placa
Modelo da caixa

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster