Nie tak dawno systemy automatyki domowej, znane także jako „inteligentny dom”, były niezwykle drogie i mogły sobie na nie pozwolić jedynie bogaci. Dziś na rynku można znaleźć wystarczająco budżetowe zestawy z czujnikami, przyciskami/wyłącznikami oraz urządzeniami wykonawczymi do zarządzania oświetleniem, gniazdkami, wentylacją, zaopatrzeniem w wodę i innymi urządzeniami. Nawet najwięksi amatorzy DIY mogą przystąpić do piękna i za niewielkie pieniądze zbudować urządzenia do inteligentnego domu.

Zazwyczaj oferowane urządzenia to albo czujniki, albo mechanizmy wykonawcze. Umożliwiają one łatwe wdrożenie scenariuszy, takich jak „włącz światło przy wykryciu ruchu” lub „wyłącznik przy wyjściu gasi światło w całym mieszkaniu”. Ale z telemetryką jakoś nie poszło. W najlepszym przypadku to wykres temperatury i wilgotności, lub chwilowa moc w konkretnym gniazdku.
Niedawno zainstalowałem liczniki wody z wyjściem impulsowym. Przy każdym litrze przepływającym przez licznik aktywuje się kontaktron i zamyka obwód. Pozostało tylko podłączyć się do przewodów i spróbować z tego uzyskać korzyści. Na przykład analizować zużycie wody według godzin i dni tygodnia. A jeśli w mieszkaniu jest kilka pionów wodnych, łatwiej widzieć wszystkie aktualne dane na jednym ekranie, niż błądzić po trudno dostępnych zakamarkach z latarką.
Pod tym wpisem znajduje się mój projekt urządzenia opartego na ESP8266, które liczy impulsy z liczników wody i przesyła odczyty na serwer inteligentnego domu przez MQTT. Programować będziemy w micropythonie z wykorzystaniem biblioteki uasyncio. Podczas tworzenia firmware'u natknąłem się na kilka interesujących trudności, o których opowiem również w tym artykule. Zaczynajmy!
Schemat

Sercem całego układu jest moduł z mikrokontrolerem ESP8266. Początkowo planowałem użyć ESP-12, ale mój okazał się wadliwy. Musiałem zadowolić się modułem ESP-07, który był dostępny. Na szczęście są one identyczne zarówno pod względem pinów, jak i funkcjonalności; różnica polega tylko na antenie — w ESP-12 jest zintegrowana, a w ESP-07 — zewnętrzna. Mimo to, nawet bez anteny sygnał WiFi w mojej łazience działa normalnie.
Obwód modułu jest standardowy:
- przycisk reset z podciągnięciem i kondensatorem (choć obie te rzeczy już znajdują się wewnątrz modułu)
- Sygnał enable (CH_PD) podciągnięty do zasilania
- GPIO15 jest podciągnięty do masy. To jest potrzebne tylko na starcie, ale nie mam nic, co mógłbym podłączyć do tego pinu, więc więcej nie jest potrzebne.
Aby przełączyć moduł w tryb programowania, należy podłączyć GPIO2 do masy, a dla wygody przewidziałem przycisk Boot. W normalnym stanie ten pin jest podciągnięty do zasilania.
Stan linii GPIO2 jest sprawdzany tylko na początku działania — podczas zasilania lub zaraz po resecie. Wtedy moduł uruchamia się jak zwykle lub przechodzi w tryb programowania. Po załadowaniu ten pin można używać jako zwykłego GPIO. A skoro już jest przycisk, można przypisać mu jakąś użyteczną funkcję.
Do programowania i debugowania będę używać UART, który wprowadziłem na płytkę stykową. Kiedy potrzeba — po prostu podłączam przejściówkę USB-UART. Trzeba tylko pamiętać, że moduł zasilany jest napięciem 3.3V. Jeśli zapomnę przełączyć przejściówkę na to napięcie i podam 5V, to moduł prawdopodobnie ulegnie zniszczeniu.
Nie mam problemów z elektrycznością w łazience — gniazdko znajduje się około metr od liczników, więc zasilanie będę brał z 220V. Jako źródło zasilania będzie mi służyć mały od Tenstar Robot. Osobiście mam problemy z elektroniką analogową i mocy, a tu gotowy zasilacz w małej obudowie.
Do sygnalizacji trybów pracy przewidziałem diodę LED podłączoną do GPIO2. Jednak nie zdecydowałem się jej lutować, ponieważ w module ESP-07 już jest dioda LED, podłączona do tego samego GPIO2. Ale na płytce niech będzie — może będę chciał wyprowadzić tę diodę na obudowę.
Przechodzimy do najciekawszego. Liczniki wody nie mają żadnej logiki, nie można zapytać o bieżące wskazania. Jedyne, co mamy, to impulsy — zaciśnięcie kontaktów przekaźnika co litr. Wyjścia przekaźników mam podłączone do GPIO12/GPIO13. Rezystor podciągający będę włączać programowo wewnątrz modułu.
Początkowo zapomniałem przewidzieć rezystory R8 i R9, więc w mojej wersji płytki ich nie ma. Ale skoro już publikuję schemat do publicznego wglądu, warto naprawić tę niedopatrzenie. Rezystory są potrzebne, aby nie spalić portu w przypadku, gdy programowanie się zawiesi i ustawi jeden na pinie, a przekaźnik zamknie tę linię na masę (z rezystorem popłynie maksymalnie 3.3V/1000Ω = 3.3mA).
Czas pomyśleć o tym, co zrobić, jeśli zniknie prąd. Pierwszą opcją jest zapytanie serwera o początkowe wartości liczników na starcie. Jednak wymagałoby to znacznego skomplikowania protokołu wymiany. Co więcej, działanie urządzenia w takim przypadku zależy od stanu serwera. Gdyby serwer po wyłączeniu prądu nie uruchomił się (lub uruchomił się później), licznik wody nie mógłby zapytać o początkowe wartości i działałby nieprawidłowo.
Dlatego zdecydowałem się zrealizować przechowywanie wartości liczników w układzie pamięci podłączonym przez I2C. Nie mam szczególnych wymagań co do rozmiaru pamięci flash—trzeba zapisać tylko 2 liczby (liczba litrów z liczników ciepłej i zimnej wody). Nawet najmniejszy moduł się nada. Zwróć uwagę na liczbę cykli zapisu. Większość modułów ma 100 tys. cykli, niektóre do miliona.
Wydawałoby się, że milion to dużo. Jednak przez 4 lata mieszkania w moim mieszkaniu zużyłem nieco ponad 500 metrów sześciennych wody, co daje 500 tys. litrów! A to 500 tys. zapisów w pamięci flash. I to tylko zimna woda. Można by oczywiście przepinać układ co kilka lat, ale okazało się, że są układy FRAM. Z punktu widzenia programowania to ten sam EEPROM I2C, tylko z ooooogromną liczbą cykli zapisu (setki milionów). Tylko wciąż nie mogę się wybrać do sklepu z takimi układami, więc na razie pozostanie zwykła 24LC512.
Płytka drukowana
Początkowo planowałem zrobić płytkę w warunkach domowych. Dlatego zaprojektowałem płytkę jako jednostronną. Jednak po zmarnowaniu godziny z żelazkiem laserowym i maską lutowniczą (bez niej jakoś nie wypada), postanowiłem zamówić płytki u Chińczyków.

Tuż przed zamówieniem płytki zdałem sobie sprawę, że poza układem pamięci flash mogę podłączyć coś jeszcze użytecznego do magistrali I2C, na przykład wyświetlacz. Co dokładnie na niego wyświetlać—na razie pytanie, ale trzeba to rozprowadzić na płytce. Skoro zamierzam zamówić płytki w fabryce, nie miało sensu ograniczać się do jednostronnej płytki, więc ścieżki na I2C będą jedynymi na tylniej stronie płytki.
Jednoliniowa płytka miała też swój duży problem. Ponieważ projekt została wykonana jako jednolita, ścieżki i komponenty SMD miały być umieszczone po jednej stronie, a złącza i zasilacz po drugiej. Kiedy po miesiącu otrzymałem płytki, zapomniałem o pierwotnym planie i zamontowałem wszystkie komponenty na stronie frontowej. Dopiero gdy doszło do lutowania zasilacza okazało się, że plus i minus są odwrotnie. Musiałem improwizować za pomocą zworek. Na powyższym obrazku już zmieniłem układ, ale masa przechodzi z jednej części płytki do drugiej przez wyprowadzenia przycisku Boot (choć można by było poprowadzić ścieżkę na drugim poziomie).
Wyszło tak

Obudowa
Następny krok — obudowa. Posiadając drukarkę 3D, to żaden problem. Nie kłopotałem się z tym szczególnie — po prostu narysowałem pudełko o odpowiednich wymiarach i zrobiłem nacięcia w odpowiednich miejscach. Pokrywa mocowana jest do obudowy małymi wkrętami.

Wspominałem już, że przycisk Boot może być użyty jako przycisk uniwersalny — właśnie go wyprowadzimy na przedni panel. W tym celu narysowałem specjalne 'gniazdo', w którym znajduje się przycisk.

Wewnątrz obudowy znajdują się też podkładki, na które montuje się płytkę i mocuje ją jedynym śrubą M3 (na płytce nie było więcej miejsca).
Wyświetlacz dobrałem już wtedy, gdy wydrukowałem pierwszy próbny wariant obudowy. Standardowy wyświetlacz dwuliniowy nie mieścił się w tej obudowie, ale w zakamarkach znalazł się wyświetlacz OLED SSD1306 128×32. Trochę mały, ale nie muszę na niego codziennie patrzeć — wystarczy.
Zastanawiając się, jak będą poprowadzone przewody, postanowiłem umieścić wyświetlacz na środku obudowy. Ergonomia oczywiście jest niska — przycisk na górze, wyświetlacz na dole. Ale już mówiłem, że pomysł przymocowania wyświetlacza przyszedł zbyt późno i nie miałem ochoty przerabiać płytki, by przenieść przycisk.
Urządzenie w całości. Moduł wyświetlacza jest przyklejony na klej termokurczliwy.


Ostateczny rezultat można zobaczyć na KDPW.
Oprogramowanie
Przejdźmy do części programowej. Do takich małych projektów bardzo lubię używać języka Python ()- kod jest bardzo zwięzły i zrozumiały. Na szczęście nie ma potrzeby wchodzić na poziom rejestrów, aby wycisnąć mikroskundy — wszystko można zrobić z Pythona.
Wygląda na to, że wszystko jest proste, ale nie do końca – w urządzeniu planowane są różne niezależne funkcje:
- Użytkownik naciska przycisk i patrzy na wyświetlacz
- Litry odliczają i aktualizują wartości w pamięci flash
- Moduł monitoruje sygnał WiFi i przestawia się, jeśli to konieczne
- Bez migającej lampki w ogóle się nie da
Nie można dopuścić do sytuacji, w której jedna funkcja nie działa, gdy inna z jakiegoś powodu ma problemy. Mam już dość kaktusów w innych projektach i teraz dostrzegam błędy w stylu "przegapiliśmy kolejny litr, bo w tym momencie aktualizował się wyświetlacz" lub "użytkownik nic nie może zrobić, podczas gdy moduł łączy się z WiFi". Oczywiście, niektóre rzeczy można robić za pomocą przerwań, ale można natrafić na ograniczenia dotyczące czasu trwania, zagnieżdżenia wywołań lub nieatomowych zmian zmiennych. A kod zajmujący się wszystkim naraz szybko zamienia się w chaos.
W Używałem klasycznego preemptive multitasking i FreeRTOS, ale w tym przypadku znacznie bardziej odpowiedni okazał się model . Co więcej, implementacja krotek w Pythonie jest po prostu niesamowita – dla programisty wszystko jest zrobione prosto i wygodnie. Po prostu pisz logikę, tylko powiedz, w których miejscach można przełączać się między wątkami.
Różnice między preemptive a cooperative multitasking proponuję zgłębić fakultatywnie. A teraz przejdźmy w końcu do kodu.
#####################################
# 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 foreverKażdy licznik jest obsługiwany przez instancję klasy Counter. Na początku należy odczytać początkową wartość licznika z EEPROM (value_storage) – w ten sposób realizowane jest przywracanie po zniknięciu zasilania.
Pin jest inicjalizowany z wbudowanym podciągnięciem do zasilania: jeśli kontakt jest zamknięty – na linii jest zero, jeśli otwarty, linia jest podciągana do zasilania, a kontroler odczytuje jedynkę.
Uruchamiana jest również osobna zadania, które będzie przeprowadzać odczyty pinów. Każdy licznik uruchomi swoje zadanie. Oto jego kod
""" Sprawdź pin i zwiększ wartość, gdy minęło kolejny litr """
async def _switchcheck(self):
last_checked_pin_state = self._pin.value() # Odczytaj początkowy stan
# Monitoruj zmiany pinu
while True:
state = self._pin.value()
if state != last_checked_pin_state:
# Stan się zmienił: działać teraz na tym.
last_checked_pin_state = state
if state == 0:
self._another_litre_passed()
# Ignoruj dalsze zmiany stanu, dopóki przełącznik się ustabilizuje
await asyncio.sleep_ms(Counter.debounce_ms)Opóźnienie 25 ms jest potrzebne do filtrowania drgań styków, a jednocześnie reguluje, jak często obudzona jest zadanie (gdy to zadanie śpi, inne zadania pracują). Co 25 ms funkcja budzi się, sprawdza pin, a jeśli styki hermetyczne są zamknięte, oznacza to, że przez licznik przeszła kolejna litr i to należy obsłużyć.
def _another_litre_passed(self):
self._value += 1
self._value_changed = True
self._value_storage.write(self._value)Obsługa kolejnego litra jest trywialna — po prostu zwiększa licznik. No i nową wartość dobrze by było zapisać na pamięci flash.
Dla wygody użytkowania przewidziane są „dostępatory”
def value(self):
self._value_changed = False
return self._value
def set_value(self, value):
self._value = value
self._value_changed = FalseA teraz skorzystajmy z uroków Pythona i biblioteki uasync i zróbmy obiekt licznika waitable (jak to przetłumaczyć na polski? Taki, na który można czekać?)
def __await__(self):
while not self._value_changed:
yield from asyncio.sleep(0)
return self.value()
__iter__ = __await__ To taka wygodna funkcja, która czeka, aż wartość licznika się zaktualizuje — funkcja okazjonalnie budzi się i sprawdza znacznik _value_changed. Ciekawostką tej funkcji jest to, że kod wywołujący może zasnąć na wywołaniu tej funkcji i spać, aż otrzyma nową wartość.
A co z przerwaniami?Tak, w tym miejscu możesz mnie trochę podroczyć, mówiąc, że sam powiedziałem o przerwaniach, a w rzeczywistości zrobiłem głupie pytanie pinu. W rzeczywistości przerwania to pierwsze, co wypróbowałem. W ESP8266 można zorganizować przerwanie na zboczu i nawet napisać obsługę tego przerwania w Pythonie. W tym przerwaniu można zaktualizować wartość zmiennej. Prawdopodobnie wystarczyłoby to, gdyby licznik był urządzeniem podrzędnym — takim, które czeka, aż ktoś zapyta o tę wartość.
Niestety (a może i na szczęście?) moje urządzenie jest aktywne, powinno samo wysyłać wiadomości za pomocą protokołu MQTT i zapisywać dane w EEPROM. I tutaj pojawiają się ograniczenia — w przerwaniach nie można alokować pamięci ani używać dużego stosu, więc o wysyłaniu wiadomości przez sieć można zapomnieć. Są plusy takie jak micropython.schedule(), które pozwalają uruchomić jakąś funkcję "jak tylko będzie to możliwe", ale pojawia się pytanie "o co w tym wszystkim chodzi?". Nagle możemy wysyłać jakąś wiadomość, a tu wkracza przerwanie i psuje wartości zmiennych. Albo, na przykład, nowa wartość licznika przychodzi z serwera, podczas gdy jeszcze nie zapisaliśmy starej. Generalnie, trzeba budować synchronizację lub jakoś to obchodzić.
Czasami pojawia się również RuntimeError: schedule stack full i nie wiadomo dlaczego?
Z wyraźnym zapytaniem i uasync wygląda to w tym przypadku jakoś ładniej i pewniej.
Pracę z EEPROM wyniosłem do małej klasy.
class EEPROM():
i2c_addr = const(80)
def __init__(self, i2c):
self.i2c = i2c
self.i2c_buf = bytearray(4) # Unikaj tworzenia/zniszczenia bufora przy każdym wywołaniu
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)W Pythonie bezpośrednia praca z bajtami jest dość skomplikowana, a to właśnie bajty są zapisywane w pamięci. Musiałem zbudować konwersję między liczbą całkowitą a bajtami korzystając z biblioteki ustruct.
Aby za każdym razem nie przekazywać obiektu I2C i adresu komórki pamięci, wszystko to zawinąłem w małą i wygodną klasę.
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)Sam obiekt I2C jest tworzony z takimi parametrami.
i2c = I2C(freq=400000, scl=Pin(5), sda=Pin(4))Przechodzimy do najciekawszej części — realizacji komunikacji z serwerem za pomocą MQTT. Nie trzeba implementować samego protokołu — w internecie znalazła się . I ją będziemy używać.
Wszystko, co najbardziej interesujące, zebrano w klasie CounterMQTTClient, która bazuje na bibliotecznym MQTTClient. Zaczniemy od periferii.
#####################################
# 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))Tutaj tworzone i konfigurowane są piny lampki i przycisku, a także obiekty liczników zimnej i ciepłej wody.
Inicjalizacja nie jest taka prosta
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())Do konfigurowania parametrów działania biblioteki mqtt_as używamy dużego słownika różnych ustawień — config. Większość ustawień domyślnych nam odpowiada, ale wiele wymaga jawnego podania. Aby nie wpisywać ustawień bezpośrednio w kodzie, przechowuję je w pliku tekstowym config.txt. Umożliwia to zmianę kodu niezależnie od ustawień oraz łatwe tworzenie kilku identycznych urządzeń z różnymi parametrami.
Ostatni blok kodu uruchamia kilka korutyn w celu obsługi różnych funkcji systemu. Oto na przykład korutyna, która obsługuje liczniki
async def _counter_coro(self, counter, topic):
# Publikowanie wartości początkowej
value = counter.value()
await self.publish(topic, str(value))
# Publikowanie każdej nowej wartości
while True:
value = await counter
await self.publish_msg(topic, str(value))Korutyna w pętli czeka na nową wartość licznika, a gdy tylko się pojawi — wysyła wiadomość protokołem MQTT. Pierwsza część kodu przesyła wartość początkową, nawet jeśli woda przez licznik nie płynie.
Podstawowa klasa MQTTClient sama siebie obsługuje, sama inicjuje połączenie przez WiFi i ponownie łączy się, gdy połączenie zostaje zerwane. W przypadku zmian w stanie połączenia WiFi, biblioteka informuje nas przez wywołanie wifi_connection_handler
async def wifi_connection_handler(self, state):
self.internet_outage = not state
if state:
self.dprint('WiFi działa.')
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 działa.')
await asyncio.sleep(0)Funkcja jest uczciwie zaadaptowana z przykładów. W tym przypadku liczy liczbę przerw w dostępie do internetu (internet_outages) oraz ich czas trwania. Przy przywracaniu połączenia na serwer wysyłany jest czas przerwy.
Zresztą ostatni sleep jest potrzebny tylko po to, aby funkcja stała się asynchroniczna — w bibliotece jest wywoływana przez await, a tak mogą być wywoływane tylko funkcje w ciele których znajduje się inny await.
Oprócz połączenia z WiFi należy jeszcze nawiązać połączenie z brokerem MQTT (serwerem). Tym też zajmuje się biblioteka, a nam przypada możliwość zrobienia czegoś użytecznego, gdy połączenie jest już nawiązane.
async def mqtt_connection_handler(self, client):
await client.subscribe(self._mqtt_cold_water_theme)
await client.subscribe(self._mqtt_hot_water_theme)Tutaj subskrybujemy kilka wiadomości — serwer ma teraz możliwość ustawienia bieżących wartości liczników, wysyłając odpowiednią wiadomość.
def mqtt_msg_handler(self, topic, msg):
topicstr = str(topic, 'utf8')
self.dprint("Odebrano wiadomość MQTT 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))Ta funkcja przetwarza przychodzące wiadomości i w zależności od tematu (nazwa wiadomości) aktualizuje wartości jednego z liczników.
Kilka pomocniczych funkcji.
# 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")Ta funkcja zajmuje się wysyłaniem wiadomości, jeśli połączenie jest nawiązane. Jeśli połączenia nie ma — wiadomość jest ignorowana.
A to po prostu wygodna funkcja, która formuje i wysyła wiadomości diagnostyczne.
async def publish_debug_msg(self, subtopic, msg):
await self.publish_msg("{}\/{}".format(self._mqtt_debug_water_theme, subtopic), str(msg))
Tyle tekstu, a jeszcze nie migaliśmy diodą LED. Oto.
# 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)Przewidziałem 2 tryby migania. Jeśli połączenie zostało przerwane (lub dopiero się nawiązuje), urządzenie będzie migać szybko. Jeśli połączenie jest nawiązane — urządzenie miga co 5 sekund. W razie potrzeby można tutaj zrealizować inne tryby migania.
Ale dioda LED to tylko drobiazg. Mamy jeszcze zamiar zająć się wyświetlaczem.
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)Oto to, o czym mówiłem — jak łatwo i wygodnie korzystać z coroutine. Ta mała funkcja opisuje WSZYSTKIE interakcje z użytkownikiem. Coroutine po prostu czeka na naciśnięcie przycisku i włącza wyświetlacz na 3 sekundy. Na wyświetlaczu pojawiają się aktualne odczyty liczników.
Zostało jeszcze kilka drobiazgów. Oto funkcja, która (re)uruchamia to wszystko. Główną pętlą zajmuje się jedynie przesyłaniem różnorodnych informacji diagnostycznych raz na minutę. Ogólnie, podaję jak jest — nie trzeba specjalnie komentować.
async def main(self):
while True:
try:
await self._connect_to_WiFi()
await self._run_main_loop()
except Exception as e:
self.dprint('Global communication failure: ', e)
await asyncio.sleep(20)
async def _connect_to_WiFi(self):
self.dprint('Connecting to WiFi and MQTT')
sta_if = network.WLAN(network.STA_IF)
sta_if.connect(config['ssid'], config['wifi_pw'])
conn = False
while not conn:
await self.connect()
conn = True
self.dprint('Connected!')
self.internet_outage = False
async def _run_main_loop(self):
# Loop forever
mins = 0
while True:
gc.collect() # For RAM stats.
mem_free = gc.mem_free()
mem_alloc = gc.mem_alloc()
try:
await self.publish_debug_msg("Uptime", mins)
await self.publish_debug_msg("Repubs", self.REPUB_COUNT)
await self.publish_debug_msg("Outages", self.internet_outages)
await self.publish_debug_msg("MemFree", mem_free)
await self.publish_debug_msg("MemAlloc", mem_alloc)
except Exception as e:
self.dprint("Exception occurred: ", e)
mins += 1
await asyncio.sleep(60)No i jeszcze parę ustawień oraz stałych dla pełności opisu.
#####################################
# 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)Uruchamia się to wszystko tak.
client = CounterMQTTClient()
loop = asyncio.get_event_loop()
loop.run_until_complete(client.main())Coś się stało z moją pamięcią.
Tak więc, cały kod jest. Pliki przesyłałem za pomocą narzędzia ampy — pozwala on na przesyłanie ich na wewnętrzną (tę, która jest w ESP-07) pamięć flash, a następnie dostęp do nich z programu jak do zwykłych plików. Tam również przesłałem używane przeze mnie biblioteki mqtt_as, uasyncio, ssd1306 i collections (używane wewnątrz mqtt_as).
Uruchamiamy i… Otrzymujemy MemoryError. Im więcej próbowałem zrozumieć, gdzie dokładnie wycieka pamięć, tym szybciej pojawiał się ten błąd. Krótkie przeszukiwanie w Google doprowadziło mnie do zrozumienia, że w mikrokontrolerze jest łącznie 30 kB pamięci, w którą 65 kB kodu (wraz z bibliotekami) w ogóle się nie mieści.
Jednak istnieje rozwiązanie. Okazuje się, że micropython nie wykonuje kodu bezpośrednio z pliku .py — ten plik jest najpierw kompilowany. Co więcej, kompilacja odbywa się bezpośrednio na mikrokontrolerze, zmieniając go w kod bajtowy, który następnie jest przechowywany w pamięci. Do pracy kompilatora także potrzebna jest określona ilość pamięci operacyjnej.
Sztuczka polega na tym, aby uwolnić mikrokontroler od zasobożernej kompilacji. Można skompilować pliki na dużym komputerze, a do mikrokontrolera załadować już gotowy kod bajtowy. W tym celu trzeba pobrać firmware micropython i zbudować .
Nie pisałem Makefile, a ręcznie przeszedłem i skompilowałem wszystkie potrzebne pliki (w tym biblioteki) mniej więcej tak
mpy-cross water_counter.pyPozostało tylko załadować pliki z rozszerzeniem .mpy, nie zapominając wcześniej usunąć odpowiadające .py z systemu plików urządzenia.
Cały rozwój prowadziłem w programie (IDE?) ESPlorer. Umożliwia on wgrywanie skryptów do mikrokontrolera i natychmiastowe ich wykonywanie. W moim przypadku cała logika i tworzenie wszystkich obiektów znajdują się w pliku water_counter.py (.mpy). Aby to wszystko uruchamiało się automatycznie podczas startu, musi być też plik o nazwie main.py. Co więcej, musi to być właśnie .py, a nie wstępnie skompilowany .mpy. Oto jego trywialna zawartość
import water_counterUruchamiamy — wszystko działa. Ale wolnej pamięci jest alarmująco mało — około 1 kB. Mam jeszcze plany na rozwój funkcjonalności urządzenia, a tego kilobajta wyraźnie mi zabraknie. Ale okazało się, że i na to jest rozwiązanie.
Chodzi o to, że mimo że pliki są skompilowane do kodu bajtowego i znajdują się w wewnętrznym systemie plików, tak naprawdę są one i tak ładowane do pamięci operacyjnej i wykonywane stamtąd. Okazuje się, że micropython potrafi wykonywać kod bajtowy bezpośrednio z pamięci flash, ale w tym celu trzeba wbudować go bezpośrednio w firmware. To nie jest trudne, chociaż na moim netbooku zajęło mi to sporo czasu (tylko tam miałem linuksa).
Algorytm jest taki:
- Pobierz i zainstaluj . Ta rzecz buduje kompilator i biblioteki dla programów pod ESP8266. Buduje się zgodnie z instrukcją na głównej stronie projektu (wybrałem instalację STANDALONE=yes)
- Pobierz
- Potrzebne biblioteki wrzucić do ports/esp8266/modules w drzewie micropython
- Buduj firmware zgodnie z instrukcją w pliku
- Wgrywam oprogramowanie do mikrokontrolera (robię to na Windowsie przy użyciu programów ESP8266Flasher lub narzędzia esptool napisanego w Pythonie)
Teraz ‘import ssd1306’ będzie podnosić kod bezpośrednio z oprogramowania, a pamięć operacyjna nie będzie zużywana na ten cel. Tą sztuczką wgrałem do oprogramowania tylko kod bibliotek, podczas gdy główny kod programu działa z systemu plików. Umożliwia to łatwą modyfikację programu bez przebudowywania oprogramowania. Na ten moment mam wolne około 8,5 kB RAM-u. Umożliwi to wdrożenie wielu różnych użytecznych funkcji w przyszłości. A jeśli pamięci będzie zbyt mało, to mogę też wrzucić główny program do oprogramowania.
I co teraz z tym zrobić?
Ok, sprzęt jest zlutowany, oprogramowanie napisane, pudełko wydrukowane, urządzenie przyklejone do ściany i radośnie miga lampką. Ale na razie to czarna skrzynka (w dosłownym i przenośnym sensie) i nie ma w tym zbyt wiele pożytku. Czas coś zrobić z wiadomościami MQTT, które są wysyłane na serwer.
Mój „inteligentny dom” działa na . Moduł MQTT jest albo dostępny od razu, albo łatwo go zainstalować z rynku dodatków — już nie pamiętam, skąd go wziąłem. MQTT nie jest samowystarczalne — potrzebny jest tzw. broker — serwer, który przyjmuje, sortuje i przekazuje wiadomości MQTT klientom. Używam mosquitto, które (jak i majordomo) działa na tym samym netbooku.
Po tym, jak urządzenie choć raz wyśle wiadomość, wartość natychmiast pojawi się na liście.

Te wartości można teraz połączyć z obiektami systemu, można je wykorzystać w scenariuszach automatyzacji i poddać różnym analizom — wszystko to wykracza poza zakres tego artykułu. Osobom zainteresowanym systemem majordomo mogę polecić — kolega również buduje inteligentny dom i przystępnie opowiada o konfiguracji systemu.
Pokażę tylko kilka wykresów. To prosty wykres wartości z całej doby

Widać, że w nocy prawie nikt nie korzystał z wody. Kilka razy ktoś poszedł do toalety, i wydaje się, że filtr odwróconej osmozy wciąga kilka litrów w nocy. Rano zużycie znacznie wzrasta. Zwykle korzystam z wody z bojlera, ale tym razem chciałem wziąć kąpiel i tymczasowo przełączyłem na miejską ciepłą wodę — to również dobrze widać na dolnym wykresie.
Z tego wykresu dowiedziałem się, że korzystanie z toalety to 6-7 l wody, wzięcie prysznica — 20-30 l, mycie naczyń to około 20 l, a aby wziąć kąpiel, potrzeba 160 l. Moja rodzina zużywa dziennie około 500-600 l.
Dla szczególnie ciekawskich można zajrzeć do zapisów dotyczących każdego z osobna wartości.

Dowiedziałem się, że przy otwartym kranie woda płynie z prędkością około 1 l co 5 s.
Jednak w tej formie statystyki mogą być niezbyt wygodne do przeglądania. W majordomo są także możliwości przeglądania wykresów zużycia według dni, tygodni i miesięcy. Oto na przykład wykres zużycia w słupkach.

Na razie mam dane tylko za tydzień. Po miesiącu ten wykres będzie bardziej wymowny — każdy dzień będzie miał oddzielny słupek. Nieco psują obraz korekty wartości, które wprowadzam ręcznie (największy słupek). I wciąż nie jasne, czy źle wprowadziłem te pierwsze wartości prawie o metr mniej, czy to błąd w oprogramowaniu i nie wszystkie litry zostały uwzględnione. Potrzeba więcej czasu.
Nad samymi wykresami trzeba jeszcze popracować, wybielić, pomalować. Możliwe, że w celach diagnostycznych będę też tworzył wykres zużycia pamięci — być może coś tam ucieka. Może jakoś będę wyświetlał okresy, kiedy internetu nie było. Na razie wszystko to kręci się na poziomie pomysłu.
Podsumowanie
Dzisiaj moje mieszkanie stało się trochę mądrzejsze. Z tym małym urządzeniem będzie mi łatwiej śledzić zużycie wody w domu. Jeśli wcześniej oburzałem się: „znowu dużo wody zużyliśmy w miesiącu”, teraz będę mógł znaleźć źródło tego zużycia.
Niektórym może wydawać się dziwne, że patrzę na wskazania na ekranie, gdy jest on metr od samego licznika. Ale w niedalekiej przyszłości planuję przeprowadzkę do innego mieszkania, gdzie będzie kilka pionów wody, a same liczniki najprawdopodobniej będą umieszczone na klatce schodowej. Tak więc urządzenie do zdalnego odczytu wskazań będzie bardzo praktyczne.
Funkcjonalność urządzenia również planuję rozszerzyć. Już rozglądam się za silnikowymi zaworami. Teraz, aby przełączyć bojler-woda miejska, muszę obracać 3 krany w trudno dostępnej niszy. Byłoby znacznie wygodniej robić to jednym przyciskiem z odpowiednią sygnalizacją. No i oczywiście warto zaimplementować ochronę przed przeciekami.
W artykule przedstawiłem swoją wersję urządzenia opartego na ESP8266. Moim zdaniem, stworzyłem dość interesującą wersję oprogramowania na micropython z użyciem korutyn — prosto i ładnie. Postarałem się opisać wiele niuansów i błędów, z którymi się spotkałem po drodze. Może opisałem wszystko zbyt szczegółowo, ale osobiście jako czytelnik wolę przewinąć niepotrzebne informacje, niż potem domyślać się tego, co nie zostało powiedziane.
Jak zawsze jestem otwarty na konstruktywną krytykę.
Źródło: habr.com
