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.

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

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

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

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.

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.

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.


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 ()- 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 Ich habe klassische prĂ€emptive Multitasking und FreeRTOS verwendet, aber in diesem Fall war das Modell 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 foreverJeder 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 = FalseJetzt 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 . 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. .
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_counterWir 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 . 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
- Die benötigten Bibliotheken in ports/esp8266/modules innerhalb des Micropython-Baums ablegen.
- Die Firmware gemÀà der Anleitung in der Datei
- 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 . 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.

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, â 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.

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.

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.

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.
Quelle: habr.com
