Sluit de watermeter aan op het slimme huis.

Vroeger waren systemen voor huisautomatisering, of zoals ze vaak worden genoemd ‘slimme huizen’, ontzettend duur en alleen de rijken konden zich dat veroorloven. Tegenwoordig zijn er op de markt redelijk betaalbare sets met sensoren, knoppen/schakelaars en actuatoren verkrijgbaar voor het beheren van verlichting, stopcontacten, ventilatie, watertoevoer en andere verbruikers. Zelfs de meest onhandige DIY'er kan zich aansluiten bij de pracht en op een goedkope manier apparaten voor een slim huis samenstellen.

Sluit de watermeter aan op het slimme huis.

Over het algemeen zijn de aangeboden apparaten ofwel sensoren, ofwel actuatoren. Ze maken het gemakkelijk om scenario's te realiseren zoals ‘bij activatie van de bewegingssensor het licht aanzetten’ of ‘de schakelaar bij de uitgang dooft het licht in het hele appartement’. Maar met telemetrie is het nog niet echt gelukt. In de beste gevallen is het een grafiek van temperatuur en luchtvochtigheid, of het huidige vermogen in een specifiek stopcontact.

Onlangs heb ik zelf watermeters met een impulsuitgang geïnstalleerd. Voor elke liter die door de meter gaat, activeert een reedcontact en sluit de contact aan. Nu rest alleen nog de klus om de draden aan te sluiten en te proberen hier iets nuttigs mee te doen. Bijvoorbeeld, het analyseren van waterverbruik per uur en per dag van de week. En als er meerdere waterleidingen in het appartement zijn, is het handiger om alle actuele waarden op één scherm te zien, dan te moeten zoeken met een zaklamp in moeilijk bereikbare hoekjes.

Onderstaand vind je mijn versie van een apparaat gebaseerd op de ESP8266, dat de impulsen van de watermeters leest en via MQTT de metingen naar de slimme woningserver verzendt. We gaan programmeren in micropython met behulp van de uasyncio-bibliotheek. Tijdens het maken van de firmware stuitte ik op een aantal interessante complicaties, waarover ik ook in dit artikel zal vertellen. Laten we beginnen!

Schema

Sluit de watermeter aan op het slimme huis.

Het hart van het hele systeem is de module op de microcontroller ESP8266. Oorspronkelijk was de planning om de ESP-12 te gebruiken, maar de mijne bleek defect. Ik moest me tevredenstellen met de ESP-07 module die beschikbaar was. Gelukkig zijn ze gelijk in pinout en functionaliteit, het enige verschil is de antenne — de ESP-12 heeft een ingebouwde antenne, terwijl de ESP-07 een externe heeft. Overigens, zelfs zonder antenne wordt het WiFi-signaal in mijn badkamer prima ontvangen.

De aansluiting van de module is standaard:

  • resetknop met pull-up en condensator (hoewel beide al in de module zitten)
  • Enable-signaal (CH_PD) is verbonden met de voeding
  • GPIO15 is pulled to ground. This is only necessary at startup, but I have nothing else to connect to this pin anyway.

To put the module into programming mode, GPIO2 needs to be connected to ground, and to make it easier, I have provided a Boot button. In normal operation, this pin is pulled to power.

The state of GPIO2 is checked only at the beginning of operation — when power is supplied or immediately after a reset. Thus, the module either boots as usual or enters programming mode. After booting, this output can be used as a normal GPIO. And since there’s already a button, I can attach some useful function to it.

For programming and debugging, I will use the UART that I brought out to the header. When needed, I just connect a USB-UART adapter to it. Just remember that the module is powered by 3.3V. If you forget to switch the adapter to this voltage and apply 5V, the module will most likely burn out.

I don't have any issues with electricity in the bathroom — the outlet is located about a meter from the meters, so I will power it from 220V. I will use a small block HLK-PM03 from Tenstar Robot. Personally, I struggle with analog and power electronics, and here is a ready-made power supply in a small case.

For signaling the operating modes, I have provided an LED connected to GPIO2. However, I didn't solder it because the ESP-07 module already has an LED connected to the same GPIO2. But let it be on the board — in case I want to bring this LED out to the case.

Now we get to the most interesting part. The water meters have no logic; you can't ask for the current readings. The only thing available to us is pulses — contact closure from a reed switch every liter. The terminals of the reed switches are connected to GPIO12/GPIO13. I will enable the pull-up resistor programmatically within the module.

Initially, I forgot to include resistors R8 and R9, and in my version of the board, they are absent. But since I'm posting the schematic for public view, it's worth correcting this oversight. Resistors are needed to prevent burning the port in case the firmware glitches and sets the pin to high, while the reed switch shorts this line to ground (with a resistor, the maximum current will be 3.3V/1000Ω = 3.3mA).

Het is tijd om na te denken over wat te doen als de elektriciteit uitvalt. De eerste optie is om bij de start de beginwaarden van de tellers van de server op te vragen. Maar dit zou een aanzienlijke complicatie van het communicatieschema vereisen. Bovendien hangt de functionaliteit van het apparaat in dat geval af van de status van de server. Als de server na de stroomuitval niet opstartte (of pas later opstartte), kon de watermeter de beginwaarden niet opvragen en zou deze onjuist werken.

Daarom besloot ik om de waarden van de tellers op te slaan in een geheugenchip die via I2C is aangesloten. Ik heb geen speciale eisen wat betreft de grootte van het flash-geheugen — het is nodig om slechts 2 getallen op te slaan (aantal liters via de warm- en koudwatermeters). Zelfs de kleinste module zal voldoen. Maar het aantal schrijfcycli moet wel in de gaten gehouden worden. Bij de meeste modules is dit 100.000 cycli, bij sommige tot een miljoen.

Het lijkt misschien dat een miljoen veel is. Maar in de 4 jaar dat ik in mijn appartement woon, heb ik net iets meer dan 500 kubieke meter water verbruikt, dat is 500.000 liters! En 500.000 schrijfbewerkingen in de flash. En dit is alleen voor koud water. Natuurlijk zou ik de chip elke paar jaar kunnen vervangen, maar er zijn ook FRAM-chips. Vanuit programmeerkundig perspectief is het dezelfde I2C EEPROM, alleen met een ongelooflijk groot aantal herschrijfcycli (hunderd miljoenen). Maar ja, ik ben nog niet bij de winkel gekomen om zulke chips te kopen, dus voorlopig moet ik het doen met de gewone 24LC512.

Circuitboard

Oorspronkelijk was ik van plan om de printplaat thuis te maken. Daarom was de plaat ontworpen als een eendaagse. Maar na meer dan een uur te hebben geklungeld met een laserprinter en soldeermasker (die is wel nodig), besloot ik uiteindelijk om de printplaten bij de Chinezen te bestellen.

Sluit de watermeter aan op het slimme huis.

Net voordat ik de printplaten bestelde, realiseerde ik me dat ik naast de flash-geheugenchip ook iets anders nuttigs op de I2C-bus kon aansluiten, bijvoorbeeld een display. Wat ik precies op het display moet tonen — dat is nog een vraag, maar het moet wel op de printplaat worden aangesloten. En omdat ik van plan ben om de printplaten bij de fabriek te bestellen, had het geen zin om me te beperken tot een eenzijdige printplaat, dus worden de lijnen op de I2C de enige op de achterkant van de printplaat.

Met de eenzijdige bedrading was er ook een groot probleem. Aangezien de printplaat eenzijdig was ontworpen, was het de bedoeling om de sporen en SMD-componenten aan de ene kant te plaatsen en de doorvoerelementen, connectors en voedingsblok aan de andere kant. Toen ik een maand later de platen ontving, vergat ik het oorspronkelijke plan en soldeerde alle componenten aan de voorzijde. Pas toen het tijd was om het voedingsblok aan te solderen, bleek dat plus en min omgedraaid waren. Ik moest gaan improviseren met bruggen. Op de afbeelding hierboven heb ik de bedrading al aangepast, maar de aarding gaat van de ene kant van de plaat naar de andere via de aansluitingen van de Boot-knop (hoewel het ook mogelijk was om een spoor op de tweede laag aan te leggen).

Het resultaat ziet er zo uit

Sluit de watermeter aan op het slimme huis.

Behuizing

De volgende stap is de behuizing. Met een 3D-printer is dit geen probleem. Ik maakte me er niet al te veel druk om — gewoon een doos met de juiste afmetingen getekend en sneden op de juiste plekken gemaakt. Het deksel wordt met kleine schroeven aan het lichaam bevestigd.

Sluit de watermeter aan op het slimme huis.

Ik heb al eerder vermeld dat de Boot-knop kan worden gebruikt als een algemene knop — dus die gaan we naar de voorkant verplaatsen. Hiervoor heb ik een speciaal "kuiltje" getekend waar de knop komt te zitten.

Sluit de watermeter aan op het slimme huis.

Binnenin de behuizing bevinden zich ook steuntjes waarop de printplaat wordt geplaatst en met één enkele M3-schroef wordt vastgezet (er was niet meer ruimte op de printplaat).

Ik koos het display pas nadat ik het eerste voorbeeld van de behuizing had geprint. De standaard tweeregelige display paste niet in deze behuizing, maar in de voorraad vond ik een OLED-display SSD1306 128×32. Het is een beetje aan de kleine kant, maar ik kijk er niet elke dag naar — het voldoet.

Het leek me het beste om het display in het midden van de behuizing te plaatsen. De ergonomie is natuurlijk ondermaats — de knop bovenaan, het display onderaan. Maar ik heb al gezegd dat het idee om het display te monteren te laat kwam en ik had geen zin om de printplaat opnieuw te bedraden om de knop te verplaatsen.

Het apparaat is in elkaar gezet. De display-module is met hete lijm geplakt.

Sluit de watermeter aan op het slimme huis.

Sluit de watermeter aan op het slimme huis.

Het eindresultaat is te zien op KDPV

Firmware

Laten we overgaan op het softwaregedeelte. Voor zulke kleine projecten vind ik het erg leuk om Python (micropython)- te gebruiken; de code is zeer compact en begrijpelijk. Gelukkig is er geen noodzaak om naar het registerniveau te gaan om microseconden te winnen — alles kan vanuit Python worden gedaan.

Het lijkt allemaal eenvoudig, maar dat is het niet — het apparaat heeft meerdere onafhankelijke functies die moeten worden uitgevoerd:

  • De gebruiker drukt op de knop en kijkt naar het display.
  • De liters tikken en werken de waarden in het flash-geheugen bij.
  • De module houdt het WiFi-signaal in de gaten en reconnekt indien nodig.
  • Zonder een knipperend lampje kan het helemaal niet.

Het mag niet gebeuren dat de ene functie niet werkt terwijl de andere om de een of andere reden haperend is. Ik heb al genoeg problemen gehad in andere projecten en zie nu al de bugs in de trant van 'we hebben een liter gemist omdat het display op dat moment werd bijgewerkt' of 'de gebruiker kan niets doen zolang de module met WiFi verbindt'. Natuurlijk kunnen sommige dingen via interrupts worden gedaan, maar je kunt tegen beperkingen aanlopen met betrekking tot de duur, nesting van aanroepen of niet-atomare wijzigingen van variabelen. En de code die alles tegelijk probeert te doen, wordt al snel rommelig.

In projecten van een serieuzere aard. Ik heb klassieke pre-emptieve multitasking en FreeRTOS gebruikt, maar in dit geval bleek het model van co-routines en de uasync-bibliotheek veel geschikter. De Python-implementatie van co-routines is echt geweldig — voor de programmeur is alles eenvoudig en handig gemaakt. Je schrijft gewoon je logica, en geeft alleen aan op welke plekken je tussen de threads kunt overschakelen.

De verschillen tussen pre-emptieve en concurrente multitasking stel ik voor als optioneel studiegebied. Maar nu laten we eindelijk naar de code gaan.

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

Iedere teller wordt behandeld door een instantie van de klasse Counter. Eerst wordt de beginwaarde van de teller uit de EEPROM (value_storage) gelezen — zo wordt herstel na stroomuitval gerealiseerd.

De pin wordt geïnitialiseerd met een interne pull-up: als de reedcontact gesloten is, staat er nul op de lijn, als hij open is, wordt de lijn naar de voedingsspanning getrokken en leest de controller een een.

Daarnaast wordt er een aparte taak gestart die de pin zal polsen. Iedere teller zal zijn eigen taak starten. Hier is de code:

    """ Poll pin and advance value when another litre passed """
    async def _switchcheck(self):
        last_checked_pin_state = self._pin.value()  # Get initial state

        # Poll for a pin change
        while True:
            state = self._pin.value()
            if state != last_checked_pin_state:
                # State has changed: act on it now.
                last_checked_pin_state = state
                if state == 0:
                    self._another_litre_passed()

            # Ignore further state changes until switch has settled
            await asyncio.sleep_ms(Counter.debounce_ms)

Een vertraging van 25 ms is nodig voor het filteren van contactbounce, en tegelijkertijd regelt het hoe vaak de taak wakker wordt (terwijl deze taak slaapt, zijn er andere taken actief). Elke 25 ms wordt de functie wakker, controleert de pin en als de contacten van de reed-switch zijn gesloten, betekent dit dat er een liter is gepasseerd via de teller en dat moet worden afgehandeld.

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

        self._value_storage.write(self._value)

De afhandeling van de volgende liter is triviaal — de teller wordt gewoon verhoogd. En het nieuwe waarde zou ook goed naar de flash moeten worden geschreven.

Voor gebruiksgemak zijn er "toegangspunten" voorzien.

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

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

Laten we nu gebruik maken van de voordelen van Python en de uasync-bibliotheek en het tellerobject wachtbaar maken (hoe vertaal je dat naar het Nederlands? Iets dat je kunt afwachten?)

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

        return self.value()

    __iter__ = __await__  

Dit is een handige functie die wacht totdat de waarde van de teller is bijgewerkt — de functie wordt van tijd tot tijd wakker en controleert de vlag _value_changed. Het interessante aan deze functie is dat de aanroepende code kan 'slapen' tijdens het aanroepen van deze functie en pas wakker wordt bij het verkrijgen van een nieuwe waarde.

En hoe zit het met onderbrekingen?Ja, op dit punt kun je me treiteren, want ik zei zelf iets over onderbrekingen, maar in werkelijkheid heb ik een domme polling van de pin geregeld. In werkelijkheid zijn onderbrekingen het eerste dat ik heb geprobeerd. In de ESP8266 kun je een onderbreking op de op- of neergaande flank organiseren, en zelfs een handler voor deze onderbreking in Python schrijven. In deze onderbreking kun je de waarde van de variabele bijwerken. Waarschijnlijk zou dit voldoende zijn als de teller een slavin toestel was — iets dat wacht tot er naar de waarde wordt gevraagd.

Helaas (of gelukkig?) is mijn apparaat actief, het zou zelf berichten moeten verzenden via het MQTT-protocol en gegevens in EEPROM moeten opslaan. En hier komen de beperkingen om de hoek kijken — in interrupts kan geen geheugen worden toegewezen en kan er geen grote stack worden gebruikt, wat betekent dat we het verzenden van berichten over het netwerk kunnen vergeten. Er zijn leuke dingen zoals micropython.schedule(), die het mogelijk maken om een functie 'zo snel als mogelijk' te starten, maar dan rijst de vraag 'wat heeft het voor zin?'. Misschien sturen we nu een bepaald bericht, en dan komt er een interrupt die de waarden van variabelen verpest. Of bijvoorbeeld, er is een nieuwe waarde van de teller van de server gekomen terwijl we de oude nog niet hebben opgeslagen. Kortom, we moeten synchronisatie regelen of op een andere manier omgaan met het probleem.

En af en toe verschijnt er een RuntimeError: schedule stack full en wie weet waarom?

Met expliciete polling en uasync ziet het er in dit geval netter en betrouwbaarder uit.

De werking met EEPROM heb ik in een kleine klasse ondergebracht.

class EEPROM():
    i2c_addr = const(80)

    def __init__(self, i2c):
        self.i2c = i2c
        self.i2c_buf = bytearray(4) # Vermijd aanmaken/verwijderen van de buffer bij elke oproep


    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)

In Python is het best lastig om direct met bytes te werken, terwijl het juist bytes zijn die in het geheugen worden geschreven. Ik moest een conversie regelen tussen een geheel getal en bytes met behulp van de ustruct-bibliotheek.

Om niet elke keer het I2C-object en het geheugenadres door te geven, heb ik dit allemaal verpakt in een kleine en handige klasse.

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)

Het I2C-object zelf wordt aangemaakt met deze parameters.

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

We komen bij het meest interessante — de implementatie van communicatie met de server via MQTT. We hoeven het protocol zelf niet te implementeren — er is een kant-en-klare asynchrone implementatie. En die gaan we gebruiken.

Het meeste interessante is verzameld in de klasse CounterMQTTClient, die is gebaseerd op de bibliotheek MQTTClient. Laten we beginnen met de periferie.

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

Hier worden de pinnen van de lamp en de knop gemaakt en ingesteld, evenals de objecten voor koude en warme water tellers.

De initiatie is niet zo eenvoudig.

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

Voor het instellen van de parameters van de mqtt_as-bibliotheek wordt een grote woordenlijst met verschillende instellingen gebruikt — config. De meeste instellingen zijn standaard geschikt voor ons, maar veel instellingen moeten expliciet worden opgegeven. Om de instellingen niet direct in de code te moeten schrijven, bewaar ik ze in een tekstbestand config.txt. Dit maakt het mogelijk om de code onafhankelijk van de instellingen te wijzigen en om verschillende apparaten met verschillende parameters te maken.

Blok code aan het einde start verschillende coroutines voor het beheren van verschillende functies van het systeem. Hier is bijvoorbeeld een coroutine die de tellers beheert.

    async def _counter_coro(self, counter, topic):
        # Publiceer de beginwaarde
        value = counter.value()
        await self.publish(topic, str(value))

        # Publiceer elke nieuwe waarde
        while True:
            value = await counter
            await self.publish_msg(topic, str(value))

De coroutine wacht in een lus op een nieuwe waarde van de teller en zodra deze beschikbaar is, wordt er een bericht verzonden via het MQTT-protocol. Het eerste stuk code verzendt de beginwaarde, zelfs als het water niet door de teller stroomt.

De basisclass MQTTClient beheert zichzelf, initieert verbinding via WiFi en herverbinding zodra de verbinding wegvalt. Bij veranderingen in de status van de WiFi-verbinding informeert de bibliotheek ons door middel van de aanroep wifi_connection_handler.

    async def wifi_connection_handler(self, state):
        self.internet_outage = not state
        if state:
            self.dprint('WiFi is actief.')
            duration = ticks_diff(ticks_ms(), self.internet_outage_start) // 1000
            await self.publish_debug_msg('HerverbondenNa', duration)
        else:
            self.internet_outages += 1
            self.internet_outage_start = ticks_ms()
            self.dprint('WiFi is inactief.')
            
        await asyncio.sleep(0)

De functie is eerlijkweg overgenomen uit voorbeelden. In dit geval telt hij het aantal onderbrekingen (internet_outages) en hun duur. Bij het herstellen van de verbinding wordt de downtime naar de server gestuurd.

Trouwens, de laatste sleep is alleen nodig om de functie asynchroon te maken — in de bibliotheek wordt deze aangeroepen via await, en zo kunnen alleen functies worden aangeroepen waarin een andere await aanwezig is.

Naast de verbinding met WiFi moet er ook een verbinding worden gelegd met de MQTT-broker (server). Dit wordt ook door de bibliotheek afgehandeld, en wij krijgen de kans om iets nuttigs te doen wanneer de verbinding is opgebouwd.

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

Hierabonneer je je op meerdere berichten — de server heeft nu de mogelijkheid om de huidige waarden van de tellers in te stellen door een overeenkomstig bericht te verzenden.

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

Deze functie verwerkt de ontvangen berichten, en afhankelijk van het onderwerp (de naam van het bericht) worden de waarden van een van de tellers bijgewerkt.

Een paar hulpfuncties

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

Deze functie verstuurt een bericht als de verbinding is gelegd. Als er geen verbinding is, wordt het bericht genegeerd.

En dit is gewoon een handige functie die debug-berichten formuleert en verstuurt.

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

Zo veel tekst, en we hebben de LED nog niet laten knipperen. Kijk eens.

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

Ik heb 2 knippermodi voorzien. Als de verbinding is verbroken (of net tot stand komt), knippert het apparaat snel. Als de verbinding tot stand is gebracht, knippert het apparaat elke 5 seconden. Bij behoefte kunnen hier ook andere knippermodi worden gerealiseerd.

Maar de LED is maar een leukigheidje. We hebben ook nog een display opgezet.

    async def _display_coro(self):
        display = SSD1306_I2C(128,32, i2c)
    
        while True:
            display.poweron()
            display.fill(0)
            display.text("KOUDE: {:.3f}".format(self.cold_counter.value() / 1000), 16, 4)
            display.text("HEET:  {:.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)

Dit is wat ik bedoelde - hoe eenvoudig en handig het is met coroutines. Deze kleine functie beschrijft ALLE interactie met de gebruiker. De coroutine wacht gewoon op een knopdruk en schakelt het display 3 seconden aan. De actuele tellerstanden worden op het display weergegeven.

Er zijn nog een paar kleine dingen te doen. Dit is de functie die alles opnieuw opstart. De hoofdloop verstuurt slechts eens per minuut verschillende debug-informatie. Over het algemeen geef ik het zoals het is - ik denk niet dat ik verder commentaar moet geven.

   async def main(self):
        terwijl True:
            probeer:
                await self._connect_to_WiFi()
                await self._run_main_loop()
                    
            behalve Exception als e:
                self.dprint('Globale communicatiefout: ', e)
                await asyncio.sleep(20)

    async def _connect_to_WiFi(self):
        self.dprint('Verbinden met WiFi en MQTT')
        sta_if = network.WLAN(network.STA_IF)
        sta_if.connect(config['ssid'], config['wifi_pw'])
        
        conn = False
        terwijl niet conn:
            await self.connect()
            conn = True

        self.dprint('Verbonden!')
        self.internet_outage = False

    async def _run_main_loop(self):
        # Loop voor altijd
        mins = 0
        terwijl True:
            gc.collect()  # Voor RAM-statistieken.
            mem_free = gc.mem_free()
            mem_alloc = gc.mem_alloc()

            probeer:
                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)
            behalve Exception als e:
                self.dprint("Exceptions opgetreden: ", e)
            mins += 1

            await asyncio.sleep(60)

Nou, nog een paar instellingen en constanten voor de volledigheid van de beschrijving.

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

Dit wordt allemaal zo gestart.

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

Er is iets met mijn geheugen aan de hand.

Dus, de hele code is er. Ik heb de bestanden via de utility ampy geüpload - deze stelt je in staat om ze naar de interne (de in de ESP-07) flash te uploaden en ze vervolgens vanuit het programma als gewone bestanden toegankelijk te maken. Ook heb ik de door mij gebruikte bibliotheken mqtt_as, uasyncio, ssd1306 en collections (gebruikte binnen mqtt_as) daarheen geüpload.

We starten en... krijgen een MemoryError. Hoe meer ik probeerde te begrijpen waar precies het geheugen lekt, hoe eerder deze fout zich voordeed, hoe meer debug prints ik toevoegde. Een korte zoektocht op Google gaf me het inzicht dat de microcontroller in principe slechts 30 kB geheugen heeft waarin 65 kB code (inclusief bibliotheken) absoluut niet past.

Maar er is een oplossing. Blijkbaar voert micropython geen code rechtstreeks uit .py-bestanden uit - dit bestand wordt eerst gecompileerd. En het wordt zelfs op de microcontroller gecompileerd en omgezet in bytecode, die vervolgens in het geheugen wordt opgeslagen. Bovendien heeft de compiler ook een bepaalde hoeveelheid RAM nodig.

De truc is om de microcontroller te ontdoen van resource-intensieve compilatie. Je kunt bestanden op een krachtige computer compileren en vervolgens de kant-en-klare bytecode naar de microcontroller uploaden. Hiervoor moet je de micropython-firmware downloaden en de mpy-cross utility.

Ik heb geen Makefile geschreven, maar ik heb handmatig alle benodigde bestanden (inclusief bibliotheken) ongeveer zo gecompileerd:

mpy-cross water_counter.py

Het enige wat je nog moet doen, is de bestanden met de extensie .mpy uploaden, en vergeet niet de bijbehorende .py-bestanden van het besturingssysteem van het apparaat te verwijderen.

Ik heb alle ontwikkeling in het programma (IDE?) ESPlorer uitgevoerd. Het stelt je in staat om scripts naar de microcontroller te uploaden en ze direct uit te voeren. In mijn geval bevinden alle logica en de creatie van alle objecten zich in het bestand water_counter.py (.mpy). Maar om dit automatisch te laten starten, moet er ook een bestand met de naam main.py zijn. Dit moet echter een .py-bestand zijn, geen vooraf gecompileerd .mpy-bestand. Hier is de triviale inhoud:

import water_counter

Laten we het starten — alles werkt. Maar er is gevaarlijk weinig vrije geheugen – ongeveer 1 kB. Ik heb nog plannen om de functionaliteit van het apparaat uit te breiden, en dit kilobyte zal duidelijk niet genoeg zijn. Maar het bleek dat er ook hiervoor een oplossing is.

Het zit zo. Zelfs wanneer de bestanden zijn gecompileerd naar bytecode en zich in het interne besturingssysteem bevinden, worden ze in werkelijkheid nog steeds in het RAM geladen en van daaruit uitgevoerd. Maar het blijkt dat micropython bytecode rechtstreeks vanuit flash-geheugen kan uitvoeren, maar daarvoor moet het direct in de firmware worden ingebouwd. Dit is niet moeilijk, hoewel het op mijn netbook behoorlijk wat tijd kostte (daar had ik Linux draaien).

Het algoritme is als volgt:

  • Download en installeer ESP Open SDK. Dit verzamelt de compiler en bibliotheken voor programma's voor de ESP8266. Je assembleert het volgens de instructies op de hoofdpagina van het project (ik koos voor de installatie STANDALONE=yes).
  • Downloaden de micropython sources
  • plaats de benodigde bibliotheken in ports/esp8266/modules binnen de micropython-boom.
  • Bouw de firmware volgens de instructies in het bestand ports/esp8266/README.md
  • We upload the firmware to the microcontroller (I do this on Windows using the ESP8266Flasher or with the Python esptool).

Now, 'import ssd1306' will load the code directly from the firmware and RAM will not be consumed for this. With this trick, I only loaded the code for the libraries into the firmware, while the main program code runs from the file system. This allows for easy modification of the program without recompiling the firmware. Currently, I have about 8.5 kB of free RAM. This will allow me to implement quite a bit more useful functionality in the future. And if memory runs really low, I can also push the main program into the firmware.

And what should we do with this now?

Okay, the hardware is soldered, the firmware is written, the box is printed, the device is attached to the wall and is happily blinking its light. But for now, this is still a black box (in both the literal and figurative sense) and it has little utility. It’s time to do something with the MQTT messages that are sent to the server.

My 'smart home' runs on the Majordomo system. The MQTT module is either included out of the box or is easily installed from the add-ons market — I can’t remember how it ended up on my device. MQTT is not self-sufficient — it requires a so-called broker — a server that receives, sorts, and forwards MQTT messages to clients. I use mosquitto, which (like Majordomo) runs on the same netbook.

After the device sends a message at least once, the value will immediately appear in the list.

Sluit de watermeter aan op het slimme huis.

These values can now be linked with system objects, used in automation scenarios, and subjected to various analyses — all of this is out of the scope of this article. For those interested in the Majordomo system, I can recommend the channel Electronics in Focus — the friend also builds a smart home and provides clear explanations about setting up the system.

I will show just a couple of graphs. This is a simple graph of values for a day.

Sluit de watermeter aan op het slimme huis.
It’s clear that almost no one used water during the night. A few times someone went to the bathroom, and it seems the reverse osmosis filter consumes a couple of liters overnight. In the morning, consumption significantly increases. I usually use water from the boiler, but here I wanted to take a bath and temporarily switched to municipal hot water — this is also clearly noticeable on the lower graph.

Uit deze grafiek heb ik geleerd dat naar het toilet gaan 6-7 liter water kost, een douche 20-30 liter verbruikt, afwassen ongeveer 20 liter vereist, en voor een bad zijn er 160 liter nodig. Mijn gezin verbruikt ongeveer 500-600 liter per dag.

Voor de bijzonder nieuwsgierigen kan er gekeken worden naar de vermeldingen van elke specifieke waarde.

Sluit de watermeter aan op het slimme huis.

Hieruit heb ik geleerd dat water uit een open kraan ongeveer 1 liter per 5 seconden stroomt.

Maar in deze vorm is de statistiek waarschijnlijk niet zo makkelijk te bekijken. In majordomo zijn er nog meer mogelijkheden om het waterverbruik in grafieken per dagen, weken en maanden te bekijken. Hier is bijvoorbeeld een staafgrafiek van het verbruik.

Sluit de watermeter aan op het slimme huis.

Tot nu toe heb ik alleen gegevens voor een week. Over een maand zal deze grafiek beter inzicht geven, met voor elke dag een aparte staaf. De correcties die ik handmatig invoer verpesten het wat (de grootste staaf). Het is nog niet duidelijk of ik de eerste waarden bijna een kubieke meter te laag heb ingevoerd of dat dit een bug in de firmware is en niet alle liters zijn meegeteld. Ik heb meer tijd nodig.

De grafieken moeten nog verder gepimpt worden, witgeschilderd en geverfd. Misschien ga ik ook een grafiek van het geheugenverbruik bouwen voor testdoeleinden - misschien lekt er daar iets. Misschien ga ik op een of andere manier de periodes aangeven waarin er geen internet was. Voorlopig draait het nog op ideeënniveau.

Conclusie

Vandaag is mijn appartement een beetje slimmer geworden. Met dit kleine apparaatje kan ik gemakkelijker het waterverbruik in huis volgen. Als ik me eerder al ergerde aan "weer zoveel water verbruikt deze maand", kan ik nu de bron van dat verbruik vinden.

Sommigen vinden het misschien vreemd om de waarden op een scherm te bekijken als je een meter van de watermeter zelf staat. Maar in de niet al te verre toekomst ben ik van plan om naar een ander appartement te verhuizen, waar meerdere waterleidingen zijn, en de watermeters waarschijnlijk op het trappenhuis zijn geplaatst. Een apparaat voor afstandsmeting zal dan zeer welkom zijn.

Ik ben ook van plan de functionaliteit van het apparaat uit te breiden. Ik kijk al naar gemotoriseerde kranen. Momenteel moet ik voor het schakelen tussen de boiler en stadswater 3 kranen draaien in een moeilijk bereikbare nis. Het zou veel handiger zijn om dit met één druk op de knop te doen, met de bijbehorende indicatie. En uiteraard is het ook de moeite waard om een lekbescherming te implementeren.

In dit artikel beschrijf ik mijn variant van een apparaat gebaseerd op de ESP8266. Naar mijn mening heb ik een interessant alternatief firmware-ontwerp ontwikkeld op micropython met gebruik van coroutines — eenvoudig en aantrekkelijk. Ik heb geprobeerd om veel nuances en fouten die ik tegenkwam te beschrijven. Misschien heb ik te gedetailleerd beschreven, maar persoonlijk vind ik het als lezer makkelijker om het overtollige te overslaan dan later te moeten invullen wat niet is gezegd.

Zoals altijd sta ik open voor constructieve kritiek.

Broncode
Schema en printplaat
Model van de behuizing

Bron: habr.com

Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers 🔥 Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers | ProHoster