Wir verbinden den Wasserzähler mit dem Smart Home

Früher waren Systeme der Heimautomatisierung, oft als "Smart Home" bezeichnet, unglaublich teuer und nur für Wohlhabende erschwinglich. Heute gibt es auf dem Markt bereits recht preiswerte Sets mit Sensoren, Schaltern und Aktoren zur Steuerung von Beleuchtung, Steckdosen, Belüftung, Wasser und anderen Verbrauchern. Sogar der ungeschickteste DIY-Bastler kann sich für kleines Geld an dem wunderbaren Bau von Smart Home-Geräten beteiligen.

Wir verbinden den Wasserzähler mit dem Smart Home

Typischerweise handelt es sich bei den angebotenen Geräten entweder um Sensoren oder um Aktoren. Diese ermöglichen es, Szenarien wie "Bei Auslösung des Bewegungsmelders das Licht einzuschalten" oder "Der Schalter am Ausgang löscht das Licht in der gesamten Wohnung" einfach umzusetzen. Mit der Telemetrie hingegen hat es nicht ganz geklappt. Im besten Fall gibt es ein Diagramm zur Temperatur und Luftfeuchtigkeit oder die momentane Leistung einer bestimmten Steckdose.

Kürzlich habe ich mir Wasserzähler mit Impulsausgang installiert. Nach jedem durch den Zähler geflossenen Liter wird ein Reed-Kontakt ausgelöst und schließt den Kontakt. Jetzt kommt der schwierige Teil – die Kabel anzuschließen und daraus etwas Nützliches zu machen. Zum Beispiel das Wasserverbrauch über Stunden und Wochentage zu analysieren. Wenn es mehrere Wasserleitungen in der Wohnung gibt, ist es bequemer, alle aktuellen Werte auf einem Bildschirm zu sehen, als in schwer zugänglichen Nischen mit einer Taschenlampe zu suchen.

Unter diesem Abschnitt finden Sie meine Lösung auf Basis des ESP8266, die die Impulse der Wasserzähler zählt und die Werte über MQTT an den Smart-Home-Server sendet. Wir werden mit MicroPython und der uasyncio-Bibliothek programmieren. Bei der Erstellung der Firmware bin ich auf einige interessante Herausforderungen gestoßen, über die ich ebenfalls in diesem Artikel berichten werde. Los geht's!

Schema

Wir verbinden den Wasserzähler mit dem Smart Home

Das Herz des gesamten Systems ist das Modul auf dem Mikrocontroller ESP8266. Ursprünglich war der ESP-12 geplant, aber mein Exemplar war defekt. Ich musste mich mit dem verfügbaren ESP-07-Modul begnügen. Zum Glück sind sie in Bezug auf Pinbelegung und Funktionalität identisch; der einzige Unterschied liegt in der Antenne – der ESP-12 hat eine integrierte Antenne, während der ESP-07 eine externe hat. Allerdings wird das WiFi-Signal auch ohne Antenne in meinem Badezimmer gut empfangen.

Die Verdrahtung des Moduls ist standardmäßig:

  • Reset-Taste mit Pull-Up-Widerstand und Kondensator (obwohl beides bereits im Modul vorhanden ist)
  • Das Enable-Signal (CH_PD) ist an die Stromversorgung angeschlossen.
  • GPIO15 ist mit Masse verbunden. Das ist nur beim Start erforderlich, aber ich habe nichts mehr, was ich an diesen Pin anschließen müsste.

Um das Modul in den Flash-Modus zu versetzen, muss GPIO2 mit Masse verbunden werden, und um es bequemer zu machen, habe ich einen Boot-Knopf vorgesehen. Im Normalbetrieb wird dieser Pin an die Stromversorgung angeschlossen.

Der GPIO2-Zustand wird nur zu Beginn des Betriebs überprüft – beim Einschalten oder sofort nach einem Reset. Der Modul startet entweder wie gewohnt oder wechselt in den Programmiermodus. Nach dem Start kann dieser Pin wie ein gewöhnlicher GPIO verwendet werden. Da bereits eine Taste vorhanden ist, kann darauf eine nützliche Funktion programmiert werden.

Für Programmierung und Debugging verwende ich UART, welches auf das Board geführt wurde. Bei Bedarf schließe ich einfach einen USB-UART-Adapter an. Ich darf nur nicht vergessen, dass das Modul mit 3,3 V versorgt wird. Wenn ich vergesse, den Adapter auf diese Spannung umzustellen und 5 V anlege, wird das Modul wahrscheinlich beschädigt.

Mit der Stromversorgung im Bad habe ich keine Probleme – die Steckdose befindet sich etwa einen Meter von den Zählern entfernt, daher werde ich es mit 220 V versorgen. Als Stromquelle werde ich ein kleines HLK-PM03-Modul von Tenstar Robot verwenden. Persönlich tue ich mir schwer mit analoger und Leistungselektronik, aber hier gibt es ein fertiges Netzteil in einem kompakten Gehäuse.

Zur Anzeige des Betriebsmodus habe ich eine LED vorgesehen, die mit GPIO2 verbunden ist. Ich habe sie jedoch nicht verlötet, da das ESP-07-Modul bereits über eine LED verfügt, die ebenfalls an GPIO2 angeschlossen ist. Aber auf der Platine soll sie bleiben – für den Fall, dass ich diese LED ins Gehäuse bringen möchte.

Jetzt kommen wir zum Interessantesten. Wasserzähler haben keine Logik, man kann sie nicht nach den aktuellen Messwerten fragen. Das Einzige, was uns zur Verfügung steht, sind Impulse – das Schließen der Kontakte des Reed-Relais bei jedem Liter. Die Ausgänge der Reed-Relais sind bei mir an GPIO12/GPIO13 angeschlossen. Den Pull-Up-Widerstand werde ich programmatisch im Modul aktivieren.

Ursprünglich hatte ich vergessen, die Widerstände R8 und R9 vorzusehen, und in meiner Platine fehlen sie. Aber da ich das Schema zur allgemeinen Einsicht veröffentliche, sollte ich diesen Fehler korrigieren. Die Widerstände sind notwendig, um den Port nicht zu beschädigen, falls die Firmware fehlerhaft wird und eine Eins am Pin anzeigt, während der Reed-Relais diese Linie auf Masse schließt (mit Widerstand fließen maximal 3,3 V/1000 Ω = 3,3 mA).

Es ist an der Zeit, darüber nachzudenken, was zu tun ist, wenn der Strom ausfällt. Die erste Option wäre, beim Server die Anfangswerte der Zähler abzufragen. Doch das würde eine erhebliche Komplexität im Kommunikationsprotokoll erfordern. Darüber hinaus hängt die Funktionsfähigkeit des Geräts in diesem Fall vom Zustand des Servers ab. Wenn der Server nach einem Stromausfall nicht gestartet worden wäre (oder später startete), könnte der Wasserzähler die Anfangswerte nicht abfragen und würde somit falsch arbeiten.

Daher habe ich beschlossen, die Werte der Zähler in einem Speicherchip zu speichern, der über I2C angeschlossen ist. Besondere Anforderungen an die Größe des Flashspeichers habe ich nicht – ich muss lediglich 2 Zahlen speichern (die Litermenge anhand der Zähler für heißes und kaltes Wasser). Selbst das kleinste Modul ist dafür geeignet. Auf die Anzahl der Schreibzyklen sollte jedoch geachtet werden. Bei den meisten Modulen liegt diese bei 100.000 Zyklen, bei einigen sogar bis zu einer Million.

Es scheint zwar viel zu sein, eine Million. Aber in den vier Jahren, die ich in meiner Wohnung gelebt habe, habe ich etwas mehr als 500 Kubikmeter Wasser verbraucht, das sind 500.000 Liter! Und 500.000 Einträge auf dem Flash. Und das ist nur kaltes Wasser. Man könnte natürlich alle paar Jahre die Chips neu verlöten, aber es gibt auch FRAM-Chips. Aus Sicht der Programmierung sind sie wie I2C EEPROMs, jedoch mit einer unglaublich hohen Anzahl an Schreibzyklen (Hunderte Millionen). Nur komme ich nicht dazu, in den Laden zu fahren, der diese Chips hat, deshalb muss vorerst der normale 24LC512 herhalten.

Leiterplatte

Ursprünglich plante ich, die Platine zu Hause zu machen. Deshalb wurde die Platine als einseitig konzipiert. Aber nachdem ich über eine Stunde mit einem Laser-Transfer und Lötmasken gekämpft habe (ohne die geht es irgendwie nicht), habe ich mich entschieden, die Platinen bei den Chinesen zu bestellen.

Wir verbinden den Wasserzähler mit dem Smart Home

Kurz bevor ich die Platine bestellen wollte, stellte ich fest, dass ich neben dem Flash-Speicher auch noch etwas Nützliches über den I2C-Bus anschließen könnte, beispielsweise ein Display. Was genau ich darauf anzeigen möchte, ist noch eine offene Frage, aber ich muss es auf der Platine vorsehen. Da ich die Platinen bereits in einer Fabrik bestellen wollte, machte es keinen Sinn, mich auf eine einseitige Platine zu beschränken, daher sind die Leiterbahnen auf dem I2C – die einzigen auf der Rückseite der Platine.

Ein großer Fehler hing ebenfalls mit der einseitigen Verdrahtung zusammen. Da die Platine einseitig gezeichnet wurde, sollten die Leiterbahnen und SMD-Komponenten auf einer Seite platziert werden, während die durchführbaren Komponenten, Anschlüsse und das Netzteil auf der anderen Seite angebracht werden sollten. Als ich die Platinen einen Monat später erhielt, hatte ich den ursprünglichen Plan vergessen und alle Komponenten auf der Vorderseite verlötet. Erst als ich das Netzteil anlöten wollte, stellte ich fest, dass Plus und Minus vertauscht waren. Ich musste mit Brücken nachhelfen. Auf dem Bild oben habe ich bereits die Verdrahtung geändert, aber die Masse wird von einem Teil der Platine zum anderen über die Anschlüsse des Boot-Buttons geleitet (obwohl ich auch einen Leiterbahnen auf der zweiten Schicht hätte führen können).

So sieht es aus

Wir verbinden den Wasserzähler mit dem Smart Home

Gehäuse

Der nächste Schritt ist das Gehäuse. Mit einem 3D-Drucker ist das kein Problem. Ich habe mich nicht viel angestrengt – einfach die Box in der benötigten Größe skizziert und die Schnitte an den erforderlichen Stellen gemacht. Der Deckel wird mit kleinen Schrauben am Gehäuse befestigt.

Wir verbinden den Wasserzähler mit dem Smart Home

Ich habe bereits erwähnt, dass der Boot-Knopf als Multifunktionsknopf genutzt werden kann – also bringen wir ihn auf das Frontpaneel. Dafür habe ich einen speziellen "Sockel" entworfen, in dem der Knopf untergebracht ist.

Wir verbinden den Wasserzähler mit dem Smart Home

Im Gehäuse befinden sich auch kleine Aufsetzer, auf denen die Platine montiert und mit einer einzigen M3-Schraube fixiert wird (die Platine hat nicht mehr Platz).

Den Bildschirm habe ich ausgewählt, nachdem ich den ersten Prototyp des Gehäuses gedruckt hatte. Der Standard-Dual-Line-Bildschirm passte nicht in dieses Gehäuse, aber ich fand einen OLED-Bildschirm SSD1306 128×32 in meinem Vorrat. Zwar etwas klein, aber ich schaue nicht jeden Tag darauf – wird schon passen.

Beim Überlegen, wie die Kabel verlaufen sollen, habe ich beschlossen, den Bildschirm in der Mitte des Gehäuses zu platzieren. Die Ergonomie ist natürlich suboptimal – der Knopf oben, der Bildschirm unten. Aber ich habe auch gesagt, dass die Idee, den Bildschirm anzuschrauben, zu spät kam und ich keine Lust hatte, die Platine neu zu zeichnen, um den Knopf zu versetzen.

Vollständige Einheit. Das Displaymodul ist mit thermisch aktivem Kleber befestigt.

Wir verbinden den Wasserzähler mit dem Smart Home

Wir verbinden den Wasserzähler mit dem Smart Home

Das Endergebnis kann auf dem KDPV gesehen werden.

Firmware

Kommen wir nun zum Softwareteil. Für solche kleinen Projekte verwende ich sehr gerne die Programmiersprache Python (micropython)- der Code bleibt sehr kompakt und verständlich. Hier ist es nicht notwendig, sich mit Registerebenen zu beschäftigen, um Mikrosekunden herauszuholen – alles kann direkt in Python gemacht werden.

Es scheint alles einfach zu sein, aber das ist es nicht ganz - im Gerät sind mehrere unabhängige Funktionen vorgesehen:

  • Der Benutzer drückt den Knopf und sieht auf das Display.
  • Die Liter zählen herunter und aktualisieren die Werte im Flash-Speicher.
  • Das Modul überwacht das WiFi-Signal und verbindet sich bei Bedarf neu.
  • Ohne die blinkende Lampe geht es einfach nicht.

Es darf nicht sein, dass eine Funktion nicht funktioniert, während eine andere aus irgendeinem Grund Probleme hat. Ich habe in anderen Projekten genug Rückschläge erlebt und sehe jetzt ständig Fehler wie: „Wir haben einen weiteren Liter verpasst, weil zu diesem Zeitpunkt das Display aktualisiert wurde“ oder „Der Benutzer kann nichts tun, während das Modul sich mit WiFi verbindet“. Natürlich kann man an einigen Stellen Interrupts verwenden, aber man stößt schnell auf Einschränkungen hinsichtlich der Dauer, der Verschachtelung von Aufrufen oder unatomaren Änderungen von Variablen. Und der Code, der alles gleichzeitig erledigen soll, wird schnell chaotisch.

In in einem ernsthafteren Projekt Ich habe klassische präemptive Multitasking und FreeRTOS verwendet, aber in diesem Fall war das Modell von Kooperationsprogrammen (Coroutines) und der uasync-Bibliothek viel geeigneter. Dabei ist die Python-Implementierung der Koroutinen einfach großartig – für den Programmierer ist alles einfach und benutzerfreundlich gestaltet. Man schreibt einfach seine Logik und gibt nur an, wo zwischen den Threads gewechselt werden kann.

Unterschiede zwischen präemptivem und kooperativem Multitasking empfehle ich optional zu studieren. Aber jetzt lasst uns endlich zum Code übergehen.

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

Jeder Zähler wird von einer Instanz der Klasse Counter verarbeitet. Zuerst wird der Ausgangswert des Zählers aus dem EEPROM (value_storage) gelesen, um eine Wiederherstellung nach einem Stromausfall zu ermöglichen.

Der Pin wird mit einem integrierten Pull-Up-Widerstand initialisiert: Wenn der Reed-Kontakt geschlossen ist, ist die Linie niedrig; wenn sie geöffnet ist, zieht der Widerstand die Linie auf die Versorgungsspannung und der Controller liest eins.

Außerdem wird hier eine separate Aufgabe gestartet, die den Pin abfragt. Jeder Zähler wird seine eigene Aufgabe starten. Hier ist der Code dafür:

    """ Pin abfragen und den Wert erhöhen, wenn ein weiteres Liter gezählt wurde """
    async def _switchcheck(self):
        letzter_prüf_pin_status = self._pin.value()  # Anfangszustand abrufen

        # Nach einer Änderung des Pins abfragen
        while True:
            status = self._pin.value()
            if status != letzter_prüf_pin_status:
                # Status hat sich geändert: jetzt handeln.
                letzter_prüf_pin_status = status
                if status == 0:
                    self._another_litre_passed()

            # Weitere Statusänderungen ignorieren, bis der Schalter stabil ist
            await asyncio.sleep_ms(Counter.debounce_ms)

Eine Verzögerung von 25 ms ist erforderlich, um das Prellen von Kontakten zu filtern. Gleichzeitig regelt sie, wie oft die Aufgabe aufwacht (solange diese Aufgabe schläft, arbeiten andere Aufgaben). Alle 25 ms wird die Funktion aktiviert, überprüft den Pin, und wenn die Kontakte des Reed-Kontakts geschlossen sind, bedeutet das, dass ein weiterer Liter durch den Zähler gegangen ist und dies verarbeitet werden muss.

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

        self._value_storage.write(self._value)

Die Verarbeitung des nächsten Liters ist trivial – es wird einfach der Zähler erhöht. Zudem wäre es sinnvoll, den neuen Wert auf dem Flash-Speicher zu speichern.

Zur Benutzerfreundlichkeit gibt es "Zugriffsobjekte".

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

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

Jetzt nutzen wir die Vorzüge von Python und der uasync-Bibliothek und machen das Zählerobjekt wartbar (wie übersetzt man das ins Russische? Das, worauf man warten kann?).

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

        return self.value()

    __iter__ = __await__  

Das ist eine äußerst nützliche Funktion, die darauf wartet, dass der Zähler aktualisiert wird – die Funktion wechselt gelegentlich in den Aktivitätsmodus und überprüft das Flag _value_changed. Der Clou dieser Funktion ist, dass der aufrufende Code während des Aufrufs dieser Funktion 'schlafen' kann, bis ein neues Wert empfangen wird.

Und wie sieht es mit Interrupts aus?Ja, hier könnten Sie mich ärgern und sagen, ich hätte über Interrupts gesprochen, aber in Wirklichkeit habe ich eine dumme Abfrage umgesetzt. Tatsächlich waren Interrupts das Erste, was ich ausprobiert habe. Im ESP8266 kann man einen Interrupt am Signalübergang einrichten und sogar einen Handler für diesen Interrupt in Python schreiben. In diesem Interrupt könnte der Wert einer Variablen aktualisiert werden. Das würde wahrscheinlich ausreichen, wenn der Zähler als slave device fungiert – eines, das wartet, bis man nach diesem Wert fragt.

Leider (oder zum Glück?) ist mein Gerät aktiv, es sollte selbst Nachrichten über das MQTT-Protokoll senden und Daten im EEPROM speichern. Hier treten bereits Einschränkungen auf – im Interrupt darf kein Speicher zugewiesen und kein großer Stack verwendet werden, was bedeutet, dass wir das Senden von Nachrichten über das Netzwerk vergessen können. Es gibt Features wie micropython.schedule(), die es ermöglichen, eine Funktion "sofort nach Bedarf" auszuführen, aber da stellt sich die Frage: "Und was bringt das?" Vielleicht senden wir gerade irgendeine Nachricht, und plötzlich wird ein Interrupt ausgelöst, der die Werte der Variablen beeinträchtigt. Oder zum Beispiel, während wir das alte Zählerwert noch nicht gespeichert haben, kommt ein neues vom Server. Insgesamt muss man eine Synchronisation aufbauen oder sich anders behelfen.

Außerdem tritt von Zeit zu Zeit ein RuntimeError auf: schedule stack full und niemand weiß warum?

Mit explizitem Polling und uasync wirkt es in diesem Fall irgendwie eleganter und zuverlässiger.

Die Arbeit mit dem EEPROM habe ich in eine kleine Klasse ausgelagert.

class EEPROM():
    i2c_addr = const(80)

    def __init__(self, i2c):
        self.i2c = i2c
        self.i2c_buf = bytearray(4) # Vermeidung der Erstellung/Zerstörung des Buffers bei jedem Aufruf


    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 ist die Arbeit mit Bytes etwas kompliziert, aber schließlich werden genau diese Bytes im Speicher gespeichert. Daher musste ich eine Umwandlung zwischen Ganzzahlen und Bytes mit der Bibliothek ustruct umsetzen.

Um nicht jedes Mal das I2C-Objekt und die Adresse der Speicherzelle übergeben zu müssen, habe ich alles in eine kleine, praktische Klasse gekapselt.

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)

Das I2C-Objekt wird mit den folgenden Parametern erstellt:

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

Nun kommen wir zum spannendsten Teil — der Implementierung der Kommunikation mit dem Server über MQTT. Den Protokollaufbau selbst müssen wir nicht umsetzen — im Internet fand sich eine fertige asynchrone Implementierung. Diese werden wir verwenden.

Alle interessantesten Aspekte sind in der Klasse CounterMQTTClient zusammengefasst, die auf der Bibliothek MQTTClient basiert. Lassen Sie uns mit der Peripherie beginnen.

#####################################
# 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 werden die Pins für die Lampe und den Knopf sowie die Objekte für Kalt- und Warmwasserzähler erstellt und konfiguriert.

Die Initialisierung ist nicht ganz 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())

Für die Konfiguration der Funktionsparameter der mqtt_as-Bibliothek wird ein umfangreiches Wörterbuch mit verschiedenen Einstellungen verwendet — config. Die meisten Voreinstellungen sind für uns geeignet, aber viele Einstellungen müssen explizit festgelegt werden. Um Konfigurationen nicht direkt im Code zu hinterlegen, speichere ich sie in einer Textdatei config.txt. Das ermöglicht es, den Code unabhängig von den Einstellungen zu ändern und mehrere identische Geräte mit unterschiedlichen Parametern zu erstellen.

Der letzte Codeblock startet mehrere Coroutines zur Verwaltung verschiedener Funktionen des Systems. Hier ist zum Beispiel eine Coroutine, die die Zähler betreut.

    async def _counter_coro(self, counter, topic):
        # Veröffentliche den Anfangswert
        value = counter.value()
        await self.publish(topic, str(value))

        # Veröffentliche jeden neuen Wert
        while True:
            value = await counter
            await self.publish_msg(topic, str(value))

Die Coroutine wartet in einer Schleife auf einen neuen Zählerwert und sendet, sobald dieser verfügbar ist, eine Nachricht über das MQTT-Protokoll. Der erste Codeabschnitt sendet den Anfangswert, selbst wenn kein Wasser durch den Zähler fließt.

Die Basisklasse MQTTClient verwaltet sich selbst, initiiert die Verbindung über WiFi und stellt sie erneut her, wenn die Verbindung verloren geht. Bei Änderungen im Zustand der WiFi-Verbindung informiert uns die Bibliothek durch einen Aufruf von wifi_connection_handler.

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

Die Funktion wurde direkt aus Beispielen übernommen. In diesem Fall zählt sie die Anzahl der Ausfälle (internet_outages) und deren Dauer. Bei der Wiederherstellung der Verbindung wird die Ausfallzeit an den Server gesendet.

Übrigens, der letzte sleep ist nur notwendig, um die Funktion asynchron zu machen — in der Bibliothek wird sie über await aufgerufen, und derartige Aufrufe können nur in Funktionen verwendet werden, die ein anderes await enthalten.

Neben der Verbindung zu WiFi muss auch eine Verbindung zum MQTT-Broker (Server) hergestellt werden. Auch dies wird von der Bibliothek übernommen, wodurch uns die Möglichkeit gegeben wird, etwas Nützliches zu tun, sobald die Verbindung hergestellt ist.

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

Hier abonnieren wir mehrere Nachrichten – der Server hat nun die Möglichkeit, die aktuellen Werte der Zähler zu setzen, indem er die entsprechenden Nachrichten sendet.

    def mqtt_msg_handler(self, topic, msg):
        topicstr = str(topic, 'utf8')
        self.dprint("Empfangenes MQTT-Nachricht Thema={}, 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))

Diese Funktion verarbeitet eingehende Nachrichten, und je nach Thema (Nachrichtenname) werden die Werte eines der Zähler aktualisiert.

Ein paar Hilfsfunktionen

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

Diese Funktion kümmert sich um das Senden von Nachrichten, falls die Verbindung hergestellt ist. Wenn keine Verbindung besteht, wird die Nachricht ignoriert.

Und das ist einfach eine praktische Funktion, die Debug-Nachrichten formatiert und sendet.

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

So viel Text, und wir haben die LED noch nicht blitzen lassen. Hier ist sie.

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

Ich habe zwei Blinkmodi vorgesehen. Wenn die Verbindung verloren geht (oder sie gerade hergestellt wird), blinkt das Gerät schnell. Wenn die Verbindung steht, blinkt das Gerät alle 5 Sekunden. Hier können bei Bedarf auch andere Blinkmodi implementiert werden.

Aber die LED ist nur ein Gimmick. Wir haben ja auch das Display im Visier.

    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)

Das ist es, worüber ich gesprochen habe – wie einfach und bequem es mit Coroutinen ist. Diese kleine Funktion beschreibt ALLE Interaktionen mit dem Benutzer. Die Coroutine wartet einfach auf einen Tastendruck und schaltet das Display für 3 Sekunden ein. Auf dem Display werden die aktuellen Werte der Zähler angezeigt.

Es sind noch ein paar kleine Dinge übrig. Hier ist die Funktion, die das ganze Ding (neu) startet. Der Hauptschleifen kümmert sich nur darum, verschiedene Debugging-Informationen einmal pro Minute zu senden. Ich gebe es so weiter, wie es ist – besondere Kommentare sind, denke ich, nicht nötig.

   async def main(self):
        while True:
            try:
                await self._connect_to_WiFi()
                await self._run_main_loop()
                    
            except Exception as e:
                self.dprint('Globale Kommunikationsstörung: ', e)
                await asyncio.sleep(20)

    async def _connect_to_WiFi(self):
        self.dprint('Verbinde mit WiFi und 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('Verbunden!')
        self.internet_outage = False

    async def _run_main_loop(self):
        # Endlosschleife
        mins = 0
        while True:
            gc.collect()  # Für RAM-Statistiken.
            mem_free = gc.mem_free()
            mem_alloc = gc.mem_alloc()

            try:
                await self.publish_debug_msg("Betriebszeit", mins)
                await self.publish_debug_msg("Repubs", self.REPUB_COUNT)
                await self.publish_debug_msg("Ausfälle", 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("Ausnahme aufgetreten: ", e)
            mins += 1

            await asyncio.sleep(60)

Noch ein paar Einstellungen und Konstanten für die Vollständigkeit der Beschreibung

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

So wird das Ganze gestartet

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

Irgendwas ist mit meinem Speicher passiert

Also, der gesamte Code ist da. Ich habe die Dateien mit dem Tool ampy hochgeladen - es ermöglicht das Hochladen auf den internen (der im ESP-07) Flashspeicher und den späteren Zugriff auf die Dateien wie auf gewöhnliche Dateien im Programm. Dort habe ich auch die Bibliotheken mqtt_as, uasyncio, ssd1306 und collections (die innerhalb von mqtt_as verwendet wird) hochgeladen.

Wir starten und… erhalten einen MemoryError. Je mehr ich versuchte herauszufinden, wo genau der Speicher verloren geht, desto früher trat dieser Fehler auf, je mehr Debug-Ausgaben ich setzte. Eine schnelle Google-Suche brachte mich zu dem Verständnis, dass der Mikrocontroller grundsätzlich nur 30 kB Speicher hat, in den die 65 kB Code (einschließlich der Bibliotheken) einfach nicht passen können.

Aber es gibt einen Ausweg. Es stellt sich heraus, dass Micropython den Code nicht direkt aus der .py Datei ausführt – diese Datei wird zuerst kompiliert. Dabei wird sie direkt auf dem Mikrocontroller in Bytecode umgewandelt, der dann im Speicher gespeichert wird. Auch für die Arbeit des Compilers wird ein bestimmter Speicherplatz benötigt.

Der Trick besteht darin, den Mikrocontroller von der ressourcenintensiven Kompilierung zu befreien. Die Dateien können auf einem leistungsstarken Computer kompiliert werden, bevor der vorbereitete Bytecode auf den Mikrocontroller übertragen wird. Dazu muss die Firmware Micropython heruntergeladen und zusammengestellt werden. das Tool mpy-cross..

Ich habe kein Makefile geschrieben, sondern bin manuell vorgegangen und habe alle benötigten Dateien (einschließlich der Bibliotheken) ungefähr so kompiliert:

mpy-cross water_counter.py.

Es bleibt nur, die Dateien mit der Endung .mpy hochzuladen und dabei die entsprechenden .py-Dateien aus dem Dateisystem des Geräts zu löschen.

Die gesamte Entwicklung habe ich in der Software (IDE?) ESPlorer durchgeführt. Diese ermöglicht das Hochladen und sofortige Ausführen von Skripten auf dem Mikrocontroller. In meinem Fall befindet sich die gesamte Logik und die Erstellung aller Objekte in der Datei water_counter.py (.mpy). Damit jedoch alles automatisch beim Start ausgeführt wird, muss es auch eine Datei mit dem Namen main.py geben. Diese muss unbedingt eine .py-Datei sein und nicht das vorcompilierte .mpy. Der triviale Inhalt dieser Datei lautet:

import water_counter

Wir starten — alles funktioniert. Aber der verfügbare Speicher ist bedrohlich gering — etwa 1 KB. Ich habe noch Pläne zur Erweiterung der Funktionen des Geräts, und diese Kilobyte werden eindeutig nicht ausreichen. Aber es gibt auch in diesem Fall eine Lösung.

Die Sache ist die: Auch wenn die Dateien in Bytecode kompiliert sind und sich im internen Dateisystem befinden, werden sie dennoch in den RAM geladen und von dort ausgeführt. Es stellt sich jedoch heraus, dass Micropython in der Lage ist, Bytecode direkt aus dem Flash-Speicher auszuführen, aber dafür muss es direkt in die Firmware eingebaut werden. Das ist nicht schwierig, obwohl es auf meinem Netbook eine angemessene Zeit in Anspruch nahm (dort hatte ich Linux).

Der Algorithmus ist folgender:

  • Herunterladen und installieren ESP Open SDK. Dieses Tool stellt den Compiler und die Bibliotheken für Programme unter ESP8266 bereit. Es wird gemäß der Anleitung auf der Hauptseite des Projekts zusammengestellt (ich wählte die Installation STANDALONE=yes).
  • Herunterladen Quellcodes von Micropython
  • Die benötigten Bibliotheken in ports/esp8266/modules innerhalb des Micropython-Baums ablegen.
  • Die Firmware gemäß der Anleitung in der Datei ports/esp8266/README.md
  • Die Firmware auf den Mikrocontroller flashen (ich mache das unter Windows mit den Programmen ESP8266Flasher oder dem Python-Tool esptool).

Alles klar, jetzt wird ‚import ssd1306‘ den Code direkt aus der Firmware abrufen, und der RAM wird dafür nicht verwendet. Mit diesem Trick habe ich nur den Code der Bibliotheken in die Firmware geladen, während der Hauptcode meines Programms von dem Dateisystem läuft. Das erleichtert die Modifikation des Programms, ohne die Firmware neu kompilieren zu müssen. Momentan habe ich etwa 8,5 kB RAM frei. Das ermöglicht in Zukunft die Implementierung vieler nützlicher Funktionen. Wenn der Speicher jedoch nicht ausreicht, kann ich auch das Hauptprogramm in die Firmware integrieren.

Und was soll ich jetzt damit machen?

Okay, die Hardware ist verlötet, die Firmware ist geschrieben, das Gehäuse ist gedruckt, das Gerät ist an die Wand geklebt und blinkt fröhlich. Aber im Moment ist das alles noch eine Blackbox (im wahrsten Sinne des Wortes) und bringt noch nicht viel. Es wird Zeit, etwas mit den MQTT-Nachrichten zu machen, die an den Server gesendet werden.

Mein „Smart Home“ läuft auf dem Majordomo-System. Das MQTT-Modul ist entweder standardmäßig vorhanden oder lässt sich leicht aus dem Add-On-Marktplatz installieren – ich erinnere mich nicht mehr genau, woher ich es habe. MQTT ist nicht eigenständig – man benötigt einen sogenannten Broker – einen Server, der die MQTT-Nachrichten empfängt, sortiert und an die Clients weiterleitet. Ich benutze Mosquitto, das (wie Majordomo) ebenfalls auf demselben Netbook läuft.

Nachdem das Gerät einmal eine Nachricht gesendet hat, erscheint der Wert sofort in der Liste.

Wir verbinden den Wasserzähler mit dem Smart Home

Diese Werte können nun mit Objekten des Systems verknüpft, in Automatisierungsszenarien verwendet und verschiedenen Analysen unterzogen werden – das alles liegt außerhalb des Rahmens dieses Artikels. Wer sich für das System Majordomo interessiert, dem kann ich empfehlen, den Kanal "Elektronik im Objektiv" – ein Kollege, der ebenfalls ein Smart Home aufbaut und anschaulich über die Systemkonfiguration spricht.

Ich zeige nur ein paar Diagramme. Das ist ein einfaches Diagramm der Werte über einen Tag.

Wir verbinden den Wasserzähler mit dem Smart Home
Es ist offensichtlich, dass nachts praktisch niemand Wasser verbraucht hat. Einige Male musste jemand zur Toilette, und anscheinend saugt der Umkehrosmosefilter über Nacht ein paar Liter. Am Morgen steigt der Verbrauch deutlich an. Normalerweise nutze ich das Wasser aus dem Boiler, aber hier wollte ich ein Bad nehmen und habe vorübergehend auf das örtliche Warmwasser umgeschaltet – das ist auch im unteren Diagramm gut zu erkennen.

Aus diesem Diagramm habe ich erfahren, dass der Gang zur Toilette etwa 6-7 l Wasser verbraucht, eine Dusche 20-30 l, das Geschirrspülen rund 20 l und für ein Bad benötigt man 160 l. An einem Tag verbraucht meine Familie etwa 500-600 l.

Für besonders Neugierige gibt es Aufzeichnungen zu jedem einzelnen Wert.

Wir verbinden den Wasserzähler mit dem Smart Home

Hier habe ich erfahren, dass Wasser aus dem offenen Wasserhahn mit einer Geschwindigkeit von etwa 1 l alle 5 Sekunden fließt.

Aber in dieser Form ist es wahrscheinlich nicht sehr bequem, die Statistiken zu betrachten. In Majordomo gibt es zusätzlich die Möglichkeit, Verbrauchsgrafiken nach Tagen, Wochen und Monaten anzusehen. Hier beispielsweise ist das Verbrauchsdiagramm in Balkenform.

Wir verbinden den Wasserzähler mit dem Smart Home

Momentan habe ich nur Daten für eine Woche. In einem Monat wird dieses Diagramm aussagekräftiger sein – jeder Tag wird durch eine eigene Säule dargestellt. Die größten Säulen werden durch manuelle Anpassungen der Werte beeinträchtigt. Es ist noch unklar, ob ich die ersten Werte fast einen Kubikmeter zu niedrig eingegeben habe oder ob es einen Fehler in der Firmware gibt und nicht alle Liter gezählt wurden. Ich brauche mehr Zeit.

An den Diagrammen selbst muss ich noch feilen, sie aufhellen und anpassen. Möglicherweise werde ich auch zu Debugging-Zwecken ein Diagramm für den Speicherverbrauch erstellen – vielleicht gibt es dort ein Leck. Möglicherweise werde ich auch Zeiten anzeigen, in denen das Internet ausgefallen war. Momentan sind das alles nur Ideen.

Fazit

Heute ist meine Wohnung ein kleines Stück smarter geworden. Mit diesem kleinen Gerät wird es mir leichter fallen, den Wasserverbrauch im Haus zu überwachen. Früher habe ich mich über den hohen Wasserverbrauch im Monat geärgert, nun kann ich die Quelle dieses Verbrauchs finden.

Manche finden es vielleicht seltsam, die Anzeigen auf dem Bildschirm zu betrachten, wenn man nur einen Meter vom Zähler entfernt ist. Doch in naher Zukunft plane ich, in eine andere Wohnung umzuziehen, wo es mehrere Wasserzählermeter geben wird, und die Zähler wahrscheinlich im Treppenhaus platziert sein werden. Daher wäre ein Gerät zur Fernablesung der Verbrauchswerte sehr nützlich.

Die Funktionalität des Geräts möchte ich ebenfalls erweitern. Ich interessiere mich bereits für motorisierte Ventile. Momentan muss ich zum Umschalten zwischen Boiler und Stadtwasser drei Wasserhähne in einer schwer zugänglichen Nische drehen. Es wäre viel einfacher, dies mit einem Knopfdruck und entsprechender Anzeige zu erledigen. Außerdem sollte man auf jeden Fall einen Schutz gegen Leckagen implementieren.

In diesem Artikel erzähle ich von meiner Variante eines Geräts auf Basis des ESP8266. Meiner Meinung nach ist es mir gelungen, eine interessante Firmware mit micropython unter Verwendung von Coroutinen zu entwickeln — einfach und ansprechend. Ich habe versucht, viele Nuancen und Fehler zu beschreiben, auf die ich gestoßen bin. Vielleicht habe ich alles zu detailliert beschrieben; als Leser finde ich es einfacher, Unnötiges zu überspringen, als später das, was nicht gesagt wurde, zu ergänzen.

Wie immer bin ich offen für konstruktive Kritik.

Quellcode
Schema und Platine
Modell des Gehäuses

Quelle: habr.com

Erwerben Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server 🔥 Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server | ProHoster