Conectarea contorului de apă la casa inteligentă

Cândva, sistemele de automatizare a locuințelor, sau cum sunt adesea denumite „acasă inteligent”, erau extrem de scumpe și doar persoanele înstărite și le puteau permite. Astăzi, pe piață se pot găsi kituri destul de accesibile cu senzori, butoane / întrerupătoare și dispozitive de acționare pentru controlul iluminatului, prizelor, ventilației, alimentării cu apă și altor consumatori. Chiar și cel mai nepriceput DIY-er poate să se apuce de o activitate plăcută și să monteze dispozitive pentru acasă inteligentă fără a cheltui prea mult.

Conectarea contorului de apă la casa inteligentă

În general, dispozitivele propuse sunt fie senzori, fie mecanisme de acționare. Acestea permit realizarea ușoară a scenariilor precum „când senzorul de mișcare se activează, aprinde lumina” sau „întrerupătorul de lângă ieșire stinge lumina din întreaga apartament”. Însă telemetria nu a fost la fel de reușită. În cel mai bun caz, avem un grafic cu temperatura și umiditatea, sau puterea instantanee într-o priză specifică.

Recent, mi-am instalat contoare de apă cu ieșire pulsată. La fiecare litru trecut prin contor, se activează un reed și se închide contactul. Rămâne să ne încărcăm cablurile și să încercăm să obținem un beneficiu din asta. De exemplu, să analizăm consumul de apă pe ore și zile ale săptămânii. În cazul în care sunt mai multe coloane de apă în apartament, este mai convenient să vedem toate indicatorii actuali pe un singur ecran, decât să căutăm în colțuri greu accesibile cu o lanternă.

Sub titlu, este varianta mea de dispozitiv bazat pe ESP8266, care numără impulsurile de la contoarele de apă și trimite citirile pe serverul casei inteligente prin MQTT. Vom programa în micropython folosind biblioteca uasyncio. Când am creat firmware-ul, am dat peste câteva dificultăți interesante, despre care voi vorbi și în acest articol. Să începem!

Schema

Conectarea contorului de apă la casa inteligentă

Inima întregului sistem este un modul pe microcontrolerul ESP8266. Inițial era planificat ESP-12, dar al meu s-a dovedit a fi defect. A trebuit să mă mulțumesc cu modul ESP-07, care era disponibil. Din fericire, ele sunt identice în ceea ce privește pini și funcționalitate, diferența este doar în antenă — la ESP-12 este încorporată, iar la ESP-07 este externă. Totuși, chiar și fără antenă, semnalul WiFi în baia mea se recepționează în mod normal.

Conexiunea modulului este standard:

  • buton de reset cu pull-up și condensator (deși ambele sunt deja incluse în modul)
  • semnalul enable (CH_PD) este conectat la alimentare
  • GPIO15 este conectat la masă. Acest lucru este necesar doar la pornire, dar oricum nu am nimic altceva de atașat la acest pin.

Pentru a comuta modul în modul de programare, trebuie să conectezi GPIO2 la masă, iar pentru comoditate am prevăzut un buton de Boot. În starea normală, acest pin este conectat la alimentare.

Starea liniei GPIO2 este verificată doar la începutul funcționării — la alimentare sau imediat după resetare. Astfel, modulul se încarcă fie în mod obișnuit, fie trece în modul de programare. După încărcare, acest pin poate fi folosit ca un GPIO obișnuit. Și pentru că avem deja un buton, putem adăuga o funcție utilă pe el.

Pentru programare și depanare, voi folosi UART, pe care l-am scos pe conector. Când este nevoie, pur și simplu conectez un adaptor USB-UART. Trebuie doar să nu uit că modulul funcționează la 3.3V. Dacă uit să comut adaptorul pe această tensiune și aplic 5V, modulul se va arde cel mai probabil.

Nu am probleme cu electricitatea în baia mea — priza este situată la aproximativ un metru de contoare, așa că o voi alimenta de la 220V. Ca sursă de alimentare, voi folosi un mic bloc HLK-PM03 de la Tenstar Robot. Personal, mă descurc greu cu electronică analogică și de putere, iar acum am un alimentator gata în carcasă mică.

Pentru a semnaliza modurile de funcționare, am prevăzut un LED, conectat la GPIO2. Totuși, nu l-am sudat, deoarece modulul ESP-07 are deja un LED, conectat tot la GPIO2. Dar pe placă să fie — poate vreau să scot acest LED pe carcasă.

Trecem la partea cea mai interesantă. Contoarele de apă nu au nicio logică, nu poți cere citirile curente. Singurul lucru disponibil sunt impulsurile — închiderea contactelor reed pentru fiecare litru. Ilegile reed-urilor mele sunt conectate la GPIO12/GPIO13. Voi activa rezistorul de pull-up programatic în interiorul modulului.

Inițial am uitat să prevăd rezistențele R8 și R9 și în versiunea mea de placă acestea nu există. Dar deoarece deja public schema pentru a fi văzută de toți, merită să corectez această omisiune. Rezistorii sunt necesari pentru a nu arde portul în cazul în care firmware-ul dă eroare și setează o unitate pe pin, iar reed-ul scurtează această linie la masă (cu rezistor, vor trece maximum 3.3V/1000Ω = 3.3mA).

E vremea să ne gândim ce să facem dacă curentul dispare. Prima variantă este să cerem serverului valorile inițiale ale contoarelor la început. Dar aceasta ar complica semnificativ protocolul de schimb. Mai mult, funcționarea dispozitivului în acest caz depinde de starea serverului. Dacă după oprirea curentului serverul nu s-ar reporni (sau ar porni mai târziu), atunci contorul de apă nu ar putea solicita valorile inițiale și ar funcționa incorect.

Prin urmare, am decis să implementez salvarea valorilor contoarelor într-un cip de memorie conectat prin I2C. Nu am cerințe speciale privind dimensiunea memoriei flash - trebuie să salvez doar 2 numere (cantitatea de litri conform contoarelor de apă caldă și rece). Chiar și cel mai mic modul ar fi suficient. Totuși, trebuie să avem în vedere numărul de cicluri de scriere. La majoritatea modulelor, acesta este de 100 de mii de cicluri, iar la unele poate ajunge până la un milion.

S-ar părea că un milion este mult. Dar în cei 4 ani de trai în apartamentul meu, am consumat puțin peste 500 de metri cubi de apă, ceea ce reprezintă 500 de mii de litri! Și 500 de mii de înregistrări în flash. Și aceasta este doar apa rece. Desigur, aș putea resuda cipul la fiecare câțiva ani, dar am descoperit că există cipuri FRAM. Din punct de vedere al programării, este același I2C EEPROM, doar că cu un număr extrem de mare de cicluri de rescriere (sute de milioane). Dar până acum nu am reușit să ajung la magazinul cu astfel de cipuri, așa că deocamdată voi folosi un obisnuit 24LC512.

Placa de circuit imprimat

Inițial, plănuiam să fac placa în condiții casnice. Așadar, placa a fost proiectată ca fiind unidirecțională. Dar după ce am petrecut o oră întreagă cu călcatul laser și masca de sudură (fără ea nu este cum trebuie), am decis să comand plăcile de la chinezi.

Conectarea contorului de apă la casa inteligentă

Aproape de comandarea plăcilor, mi-am dat seama că, pe lângă cipul de memorie flash, pe magistrala I2C se pot contecta și alte lucruri utile, de exemplu un display. Ce anume să afișez pe el - este încă o întrebare, dar trebuie să îl conectez pe placă. Ei bine, întrucât intenționam să comand plăcile de la fabrică, nu mai avea sens să mă limitez la o placă unidirecțională, așa că liniile pe I2C sunt singurele pe partea din spate a plăcii.

O conexiune unidirecțională a fost asociată cu o mare problemă. Deoarece placa a fost desenată unidirecțional, firele și componentele SMD planificate trebuiau să fie amplasate pe o parte, iar componentele de ieșire, conectorii și sursa de alimentare pe cealaltă. Când am primit plăcile după o lună, am uitat de planul inițial și am lipit toate componentele pe partea frontală. Și doar când a venit momentul lipirii sursei de alimentare, am descoperit că plusul și minusul erau inversate. A trebuit să improvizez cu punți. În imaginea de mai sus, am modificat deja conexiunea, dar masa este transferată dintr-o parte a plăcii în cealaltă prin bornele butonului Boot (deși ar fi putut fi tras un fir pe a doua strat).

A ieșit așa

Conectarea contorului de apă la casa inteligentă

Carcasa

Următorul pas este carcasa. Dacă ai o imprimantă 3D, nu este o problemă. Nu m-am complicat prea mult — doar am desenat o cutie de dimensiunea necesară și am făcut tăieturi în locurile necesare. Capacela este fixată de carcasă cu șuruburi mici.

Conectarea contorului de apă la casa inteligentă

Am menționat deja că butonul Boot poate fi folosit ca buton de utilizare generală — așa că îl vom scoate pe panoul frontal. Pentru asta, am desenat un „puț” special unde se află butonul.

Conectarea contorului de apă la casa inteligentă

În interiorul carcasei se află și suporturi pe care se montează placa și este fixată cu un singur șurub M3 (pe placă nu a mai fost loc).

Am ales display-ul când am imprimat prima variantă a carcasei. Un display standard cu două linii nu încăpea în această carcasă, dar am găsit un display OLED SSD1306 128×32. E puțin mic, dar nu trebuie să îl privesc în fiecare zi — va merge.

Gândindu-mă cum ar trebui să fie trase firele, am decis să lipesc display-ul în mijlocul carcasei. Ergonomia este, desigur, slabă — butonul sus, display-ul jos. Dar am spus deja că ideea de a adăuga display-ul a apărut prea târziu și am fost prea leneș să reproiectez placa pentru a muta butonul.

Dispozitivul asamblat. Modulul display este lipit cu lipici termic

Conectarea contorului de apă la casa inteligentă

Conectarea contorului de apă la casa inteligentă

Rezultatul final poate fi văzut pe KDPV

Firmware

Să trecem la partea software. Pentru astfel de mici lucrări, îmi place foarte mult să folosesc limbajul Python (micropython) — codul este foarte compact și clar. Din fericire, aici nu este necesar să cobor la nivel de registre pentru a economisi microsecunde — totul poate fi realizat din Python.

Se pare că totul este simplu, dar nu foarte – în dispozitiv se preconizează mai multe funcții independente:

  • Utilizatorul apasă butonul și se uită la ecran
  • Litrii pulsează și actualizează valorile în memoria flash
  • Modulul monitorizează semnalul WiFi și se reconectează dacă este necesar
  • Dar fără lampa intermitentă, nu se poate deloc

Nu trebuie să permitem ca o funcție să nu funcționeze, dacă cealaltă dintr-un anumit motiv se blochează. M-am săturat de probleme în alte proiecte și acum văd deja erorile de genul „am ratat un litru, pentru că în acel moment se actualiza ecranul” sau „utilizatorul nu poate face nimic până când modulul se conectează la WiFi”. Desigur, unele lucruri pot fi realizate prin întreruperi, dar se poate ajunge la limitări de durată, adâncime a apelurilor sau modificări neatomice ale variabilelor. Și codul care se ocupă de toate simultan se transformă rapid într-un haos.

În un proiect mai serios am folosit multitasking clasic cu preempție și FreeRTOS, dar în acest caz, un model de co-rutine (coroutines) și biblioteca uasync . De fapt, implementarea corutinelor în Python este pur și simplu fantastică – totul este realizat simplu și convenabil pentru programatori. Pur și simplu scrie logica, doar spune unde se poate face comutarea între fire.

Diferențele dintre multitasking-ul cu preempție și cel concurent le propun să fie studiate opțional. Acum, hai să trecem în sfârșit la cod.

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

Fiecare contor este procesat de o instanță a clasei Counter. Primul lucru care se face este citirea valorii inițiale a contorului din EEPROM (value_storage) – aceasta permite recuperarea după o întrerupere de alimentare.

Pinul este inițializat cu o rezistență internă de pull-up: dacă reed-ul este închis, pe linie este zero, iar dacă este deschis, linia este trasă la alimentare iar controlerul citește unu.

De asemenea, aici se lansează o sarcină separată care va efectua sondarea pinului. Fiecare contor va lansa propria sa sarcină. Iată codul acesteia

    """ Sondare pin și avansare valoare când a trecut un alt litru """
    async def _switchcheck(self):
        last_checked_pin_state = self._pin.value()  # Obține starea inițială

        # Sondare pentru o schimbare de pin
        while True:
            state = self._pin.value()
            if state != last_checked_pin_state:
                # Starea s-a schimbat: acționează asupra ei acum.
                last_checked_pin_state = state
                if state == 0:
                    self._another_litre_passed()

            # Ignoră schimbările ulterioare de stare până când comutatorul s-a stabilizat
            await asyncio.sleep_ms(Counter.debounce_ms)

O întârziere de 25ms este necesară pentru filtrarea zgomotului de contact și, de asemenea, reglează cât de des se trezește sarcina (în timp ce această sarcină doarme, alte sarcini funcționează). La fiecare 25ms, funcția se trezește, verifică pinul și dacă contactele reed sunt închise, atunci înseamnă că un litru a trecut prin contor și trebuie să fie procesat.

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

        self._value_storage.write(self._value)

Procesarea unui nou litru este trivială — se crește pur și simplu contorul. Și, de asemenea, noua valoare ar fi bine să fie scrisă pe stick-ul de memorie.

Pentru comoditate, sunt prevăzute „accesibile”

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

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

Acum să folosim frumusețile Python și biblioteca uasync și să facem obiectul contorului așteptabil (cum să traducem asta în română? Acela care poate fi așteptat?)

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

        return self.value()

    __iter__ = __await__  

Aceasta este o funcție foarte utilă, care așteaptă până când valoarea contorului se actualizează — funcția se trezește din când în când și verifică semnalul _value_changed. Ceea ce este interesant la această funcție este că codul apelant poate dormi la apelul acestei funcții și poate dura până primește o nouă valoare.

Și cum rămâne cu întreruperile?Da, aici mă puteți trolla, spunând că eu însumi am vorbit despre întreruperi, dar de fapt am organizat un simplu sondaj al pinului. De fapt, întreruperile sunt primele pe care le-am încercat. În ESP8266 se poate organiza o întrerupere pe front, și chiar să scrii un handler pentru această întrerupere în Python. În această întrerupere, poți actualiza valoarea variabilei. Probabil că ar fi suficient dacă contorul ar fi un dispozitiv subordonat — unul care așteaptă să i se ceară acea valoare.

Din păcate (sau din fericire?) dispozitivul meu este activ, trebuie să trimită singur mesaje prin protocolul MQTT și să scrie date în EEPROM. Și aici intervin limitările — în întreruperi nu se poate aloca memorie și folosi un stack mare, așa că putem uita de trimiterea mesajelor prin rețea. Există câteva funcții utile, cum ar fi micropython.schedule(), care permit lansarea unei funcții „imediat ce e posibil”, dar apare întrebarea „și la ce bun?”. Poate tocmai acum trimitem un mesaj, iar o întrerupere intervine și strică valorile variabilelor. Sau, de exemplu, un nou valoare a contorului a sosit de pe server în timp ce încă nu am scris vechea valoare. În general, trebuie să implementăm sincronizarea sau să ne descurcăm cumva altfel.

Și din când în când apare RuntimeError: schedule stack full și cine știe de ce?

Cu sondarea explicită și uasync, în acest caz este oarecum mai elegant și mai fiabil.

Am extra un mic clase pentru gestionarea EEPROM.

class EEPROM():
    i2c_addr = const(80)

    def __init__(self, i2c):
        self.i2c = i2c
        self.i2c_buf = bytearray(4) # Evitați crearea/distrugerea buffer-ului la fiecare apel


    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)

În Python, lucrul direct cu bytes este complicat, iar bytes sunt cei care sunt scriși în memorie. A trebuit să construiesc o conversie între un număr întreg și bytes folosind biblioteca ustruct.

Pentru a nu trebui să transmit de fiecare dată obiectul I2C și adresa memoriei, am învăluit totul într-o clasă mică și convenabilă.

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)

Obiectul I2C este creat cu acești parametri.

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

Ajungem la partea cea mai interesantă — implementarea comunicării cu serverul prin MQTT. Ei bine, nu este nevoie să implementăm protocolul, am găsit pe internet o implementare asincronă gata făcută.. O vom folosi.

Toate cele mai interesante lucruri sunt adunate în clasa CounterMQTTClient, care se bazează pe biblioteca MQTTClient. Să începem cu perifericele.

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

Aici se creează și se configurează pini pentru lampa și buton, precum și obiecte pentru contoarele de apă rece și caldă.

Inițializarea nu este atât de simplă

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

Pentru a seta parametrii de funcționare ai bibliotecii mqtt_as, se folosește un mare dicționar de setări - config. Majoritatea parametrilor implicit ne sunt potriviți, dar multe setări trebuie specificate explicit. Pentru a evita scrierea setărilor direct în cod, le stochez într-un fișier text config.txt. Acest lucru permite modificarea codului independent de setări, precum și realizarea mai multor dispozitive identice cu parametrii diferiți.

Ultimul bloc de cod lansează mai multe corutine pentru a gestiona diferite funcții ale sistemului. Iată, de exemplu, o corutină care se ocupă de contoare.

    async def _counter_coro(self, counter, topic):
        # Publică valoarea inițială
        value = counter.value()
        await self.publish(topic, str(value))

        # Publică fiecare nouă valoare
        while True:
            value = await counter
            await self.publish_msg(topic, str(value))

Corutina așteaptă într-un ciclu o nouă valoare de la contor și, imediat ce aceasta apare, trimite un mesaj prin protocolul MQTT. Primul segment de cod trimite valoarea inițială chiar dacă apa nu curge prin contor.

Clasa de bază MQTTClient se întreține singură, inițiază conectarea prin WiFi și se reconectează atunci când conexiunea este pierdută. La schimbările stării conexiunii WiFi, biblioteca ne informează prin apelarea funcției wifi_connection_handler.

    async def wifi_connection_handler(self, state):
        self.internet_outage = not state
        if state:
            self.dprint('WiFi este activ.')
            duration = ticks_diff(ticks_ms(), self.internet_outage_start) / 1000
            await self.publish_debug_msg('ReconectatDupă', duration)
        else:
            self.internet_outages += 1
            self.internet_outage_start = ticks_ms()
            self.dprint('WiFi este oprit.')
            
        await asyncio.sleep(0)

Funcția este preluată direct din exemple. În acest caz, ea numără câte deconectări (internet_outages) au avut loc și durata acestora. La restabilirea conexiunii, se trimite pe server timpul de nefuncționare.

Apropo, ultima așteptare este necesară doar pentru a transforma funcția în una asincronă — în bibliotecă ea este apelată prin await, și astfel pot fi apelate doar funcțiile care conțin un alt await.

Pe lângă conexiunea WiFi, trebuie să stabilim și o conexiune cu brokerul MQTT (serverul). Acest lucru este, de asemenea, realizat de bibliotecă, iar nouă ne revine posibilitatea de a face ceva util atunci când conexiunea este stabilită.

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

Aici ne abonăm la mai multe mesaje — serverul are acum posibilitatea de a stabili valorile curente ale contoarelor trimițând un mesaj corespunzător.

    def mqtt_msg_handler(self, topic, msg):
        topicstr = str(topic, 'utf8')
        self.dprint("Mesaj MQTT primit 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))

Această funcție procesează mesajele primite, iar în funcție de temă (numele mesajului) se actualizează valorile unuia dintre contoare.

Câteva funcții auxiliare.

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

Această funcție se ocupă cu trimiterea mesajului în cazul în care conexiunea este stabilită. Dacă nu există conexiune — mesajul este ignorat.

Și aceasta este o funcție convenabilă, care formează și trimite mesaje de diagnosticare.

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

Așa mult text, iar noi încă nu am clipit LED-ul. Iată.

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

Am prevăzut 2 moduri de clipire. Dacă conexiunea s-a pierdut (sau este doar în curs de stabilire), atunci dispozitivul va clipi rapid. Dacă conexiunea este stabilită — dispozitivul clipeste o dată la 5 secunde. Dacă este necesar, aici pot fi implementate și alte moduri de clipire.

Dar LED-ul este doar un detaliu. Noi ne-am propus să lucrăm și la 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)

Asta este ceea ce spuneam — cât de simplu și convenabil este cu coroutinele. Această mică funcție descrie TOT interactiunea cu utilizatorul. Coroutina așteaptă pur și simplu apăsarea unui buton și activează afișajul timp de 3 secunde. Pe afișaj sunt prezentate citirile curente ale contoarelor.

Mai sunt și câteva detalii. Iată funcția care toate acestea (re)pornește. Ciclu principal se ocupă doar cu trimiterea diferitelor informații de diagnosticare o dată pe minut. În general, o prezint așa cum este — nu cred că mai este nevoie de comentarii speciale.

   async def main(self):
        while True:
            try:
                await self._connect_to_WiFi()
                await self._run_main_loop()
                    
            except Exception as e:
                self.dprint('Global communication failure: ', e)
                await asyncio.sleep(20)

    async def _connect_to_WiFi(self):
        self.dprint('Connecting to WiFi and 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('Connected!')
        self.internet_outage = False

    async def _run_main_loop(self):
        # Loop forever
        mins = 0
        while True:
            gc.collect()  # For RAM stats.
            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("Exception occurred: ", e)
            mins += 1

            await asyncio.sleep(60)

Mai sunt câteva setări și constante pentru a completa descrierea.

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

Se lansează totul așa.

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

Ceva s-a întâmplat cu memoria mea.

Așadar, tot codul este aici. Am încărcat fișierele folosind utilitarul ampy — acesta permite să le transfer pe flash-ul intern (cel care se află pe ESP-07) și apoi să fie accesate din program ca fișiere obișnuite. De asemenea, am încărcat bibliotecile folosite de mine: mqtt_as, uasyncio, ssd1306 și collections (folosită în interiorul mqtt_as).

Porniți și… primim MemoryError. Cu cât încercam mai mult să înțeleg unde anume scurge memoria, cu atât mai devreme apărea această eroare. O căutare rapidă pe Google m-a condus la înțelegerea că microcontrolerul are în principiu doar 30 kB de memorie în care 65 kB de cod (împreună cu bibliotecile) nu încap.

Dar există o soluție. Se pare că micropython nu execută codul direct din fișierul .py — acest fișier este mai întâi compilat. Iar compilarea se face direct pe microcontroler, transformându-se în cod bytecare, care apoi este stocat în memorie. De asemenea, pentru funcționarea compilatorului este necesar și un anumit volum de memorie RAM.

Trucul constă în a scăpa microcontrolerul de compilarea consumatoare de resurse. Se poate compila fișierele pe un computer puternic, iar în microcontroler se încarcă deja codul bytecare gata. Pentru aceasta trebuie să descărcați firmware-ul micropython și să construiți utilitarul mpy-cross.

Nu am scris un Makefile, ci am parcurs manual și am compilat toate fișierele necesare (inclusiv bibliotecile) cam așa

mpy-cross water_counter.py

Rămâne doar să încărcați fișierele cu extensia .mpy, fără a uita să ștergeți mai întâi fișierele corespunzătoare .py din sistemul de fișiere al dispozitivului.

Toată dezvoltarea am făcut-o în programul (IDE?) ESPlorer. Acesta permite încărcarea scripturilor în microcontroler și execuția lor imediată. În cazul meu, toată logica și crearea tuturor obiectelor se află în fișierul water_counter.py (.mpy). Dar pentru ca totul să pornească automat la început, trebuie să existe un fișier numit main.py. Și acesta trebuie să fie exact .py, nu un precompilat .mpy. Iată conținutul său trivial.

import water_counter

Îl pornim — totul funcționează. Dar memoria liberă este amenințător de puțin — aproximativ 1kB. Încă am planuri de extindere a funcționalității dispozitivului, iar acest kilobyte mi se va părea cu siguranță insuficient. Dar s-a dovedit că există o soluție și pentru aceasta.

Problema este următoarea. Chiar dacă fișierele sunt compilate în cod byte și se află pe sistemul de fișiere intern, în realitate acestea sunt totuși încărcate în memoria RAM și executate de acolo. Dar se pare că micropython poate executa cod byte direct din memoria flash, dar pentru aceasta trebuie să îl încorporați direct în firmware. Nu este greu, deși pe netbook-ul meu a durat destul de mult timp (doar că aveam Linux acolo).

Algoritmul este acesta:

  • Descarcă și instalează ESP Open SDK. Această unealtă compilează compilatorul și bibliotecile pentru programele sub ESP8266. Se compilează conform instrucțiunilor de pe pagina principală a proiectului (eu am ales instalarea STANDALONE=yes).
  • Descărcați sursa micropython
  • Bibliotecile necesare se pun în ports/esp8266/modules în interiorul structurii micropython.
  • Compilăm firmware-ul conform instrucțiunilor din fișierul ports/esp8266/README.md
  • Încărcăm firmware-ul în microcontroller (eu fac asta pe Windows folosind programele ESP8266Flasher sau esptool în Python)

Totul e gata, acum 'import ssd1306' va ridica codul direct din firmware și nu va consuma memoria RAM pentru asta. Astfel, am inclus în firmware doar codul bibliotecilor, în timp ce codul principal al programului meu rulează din sistemul de fișiere. Acest lucru permite modificarea ușoară a programului fără a re-compila firmware-ul. În prezent, am liber aproximativ 8.5 kB RAM. Aceasta va permite implementarea multor funcționalități utile în viitor. Dacă memoria devine insuficientă, se poate muta și programul principal în firmware.

Și ce să facem acum cu asta?

Ok, hardware-ul este asamblat, firmware-ul este scris, cutia este imprimată, dispozitivul este montat pe perete și clipește vesel cu un LED. Dar până acum, acesta este un negru mister (în sensul propriu și figurat) și nu aduce încă multe beneficii. Este timpul să facem ceva cu mesajele MQTT care sunt trimise către server.

Casa mea inteligentă rulează pe sistemul Majordomo. Modulul MQTT fie este inclus din fabrică, fie se instalează ușor din magazinul de extensii — nu-mi mai amintesc de unde l-am obținut. MQTT nu este o soluție autoconținută — este nevoie de un așa numit broker — un server care primește, sortează și redirecționează mesajele MQTT către clienți. Eu folosesc mosquitto, care (la fel ca și majordomo) rulează pe același netbook.

După ce dispozitivul trimite un mesaj măcar o dată, valoarea va apărea imediat în listă.

Conectarea contorului de apă la casa inteligentă

Aceste valori pot fi acum legate de obiectele din sistem, pot fi folosite în scenarii de automatizare și supuse unei analize diversificate — toate acestea sunt dincolo de scopul acestui articol. Cei interesați de sistemul majordomo pot fi rugați să urmărească canalul Electronică în Obiectiv — un coleg care de asemenea își construiește o casă inteligentă și explică într-un mod accesibil configurarea sistemului.

Voi arăta doar câteva grafice. Acesta este un grafic simplu al consumurilor pe parcursul unei zile

Conectarea contorului de apă la casa inteligentă
Se vede că noaptea aproape nimeni nu a folosit apă. De câteva ori cineva a mers la toaletă și, se pare că filtrul de osmoză inversă absoarbe câțiva litri pe noapte. Dimineața, consumul crește semnificativ. De obicei folosesc apă din boiler, dar aici am vrut să fac o baie și am comutat temporar pe apa caldă de la oraș — acest lucru este de asemenea ușor vizibil pe graficul de jos.

Din acest grafic am aflat că a merge la toaletă consumă 6-7l de apă, a face duș — 20-30l, a spăla vasele aproximativ 20l, iar pentru a face baie sunt necesari 160l. Într-o zi, familia mea consumă undeva între 500-600l.

Pentru cei foarte curioși, se pot consulta înregistrările pentru fiecare valoare în parte.

Conectarea contorului de apă la casa inteligentă

De aici am aflat că atunci când apa curge cu robinetul deschis, aceasta se scurge cu o viteză de aproximativ 1l în 5s.

Însă, în această formă, statisticile nu sunt foarte comod de văzut. În majordomo, există și opțiuni de a vizualiza graficele de consum pe zile, săptămâni și luni. Iată, de exemplu, graficul consumului în coloane.

Conectarea contorului de apă la casa inteligentă

Până acum am date doar pentru o săptămână. Într-o lună, acest grafic va fi mai reprezentativ — fiecărei zile îi va corespunde o coloană separată. Puțin strică imaginea ajustările valorilor pe care le introduc manual (cea mai mare coloană). Și momentan nu este clar dacă am setat greșit primele valori cu aproape un metru mai puțin, sau dacă este un bug în firmware și nu toate litrii au fost contabilizați. Este nevoie de mai mult timp.

Trebuie să mai lucrez la graficele în sine, să le albesc, să le pictez. Este posibil să construiesc, de asemenea, un grafic de consum de memorie în scopuri de testare — poate acolo este ceva ce se pierde. Este posibil să afișez cumva perioadele în care internetul a fost absent. Până acum, toate acestea sunt la stadiul de idee.

Concluzie

Astăzi apartamentul meu a devenit puțin mai inteligent. Cu acest mic dispozitiv, îmi va fi mai ușor să monitorizez consumul de apă din casă. Dacă înainte mă plângeam „iarăși am consumat multă apă într-o lună”, acum voi putea găsi sursa acestui consum.

Unii ar putea considera ciudat să privească citirile pe un ecran, dacă acesta este la un metru de contoar. Dar în viitorul nu foarte îndepărtat, plănuiesc să mă mut în alt apartament, unde vor exista mai multe coloane de apă, iar contoarele vor fi cel mai probabil amplasate pe casa scării. Așa că dispozitivul de citire la distanță va fi foarte util.

Funcționalitatea dispozitivului intenționez să o extind și eu. Mă uit deja la vane motorizate. Acum, pentru a comuta între apă de la boiler și apă de la oraș, trebuie să răsucesc 3 robinete într-un colț greu accesibil. Ar fi mult mai convenabil să fac asta cu un singur buton, cu indicația corespunzătoare. Ei bine, și, desigur, merită să implementăm protecția împotriva scurgerilor.

În articol, am prezentat varianta mea de dispozitiv bazat pe ESP8266. Din punctul meu de vedere, am reușit să realizez o variantă destul de interesantă a firmware-ului în micropython cu utilizarea coroutinei - simplu și elegant. Am încercat să descriu numeroase nuanțe și greșeli cu care m-am confruntat pe parcurs. Poate că am detaliat prea mult, personal, ca cititor, prefer să derulez informațiile suplimentare decât să încerc să completez ce n-a fost spus.

Ca întotdeauna, sunt deschis la critici constructive.

Codul sursă
Schema și placa
Modelul carcasei

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster