Podłączenie wodomierza do inteligentnego domu

Dawno, dawno temu systemy automatyki domowej, czyli „inteligentny dom”, jak je często nazywano, były strasznie drogie i tylko bogaci mogli sobie na nie pozwolić. Dziś na rynku można znaleźć dość niedrogie zestawy z czujnikami, przyciskami/przełącznikami i elementami wykonawczymi do sterowania oświetleniem, gniazdkami, wentylacją, zaopatrzeniem w wodę i innymi odbiornikami. I nawet najbardziej nieuczciwy majsterkowicz może wciągnąć się w urodę i złożyć urządzenia do inteligentnego domu w przystępnej cenie.

Podłączenie wodomierza do inteligentnego domu

Zazwyczaj proponowanymi urządzeniami są czujniki lub elementy wykonawcze. Ułatwiają realizację scenariuszy typu „po uruchomieniu czujnika ruchu włącz światło” lub „włącznik przy wyjściu wyłączy światło w całym mieszkaniu”. Ale jakoś nie wyszło z telemetrią. W najlepszym przypadku jest to wykres temperatury i wilgotności lub chwilowej mocy w określonym gniazdku.

Niedawno zainstalowałem wodomierze z wyjściem impulsowym. Z każdym litrem przepływającym przez licznik następuje aktywacja kontaktronu i zwarcie styku. Pozostaje tylko chwycić się przewodów i spróbować czerpać z tego korzyści. Na przykład przeanalizuj zużycie wody według godziny i dnia tygodnia. Cóż, jeśli w mieszkaniu jest kilka pionów wodnych, wygodniej jest zobaczyć wszystkie aktualne wskaźniki na jednym ekranie, niż wspinać się z latarką do trudno dostępnych nisz.

Poniżej wycięcia moja wersja urządzenia opartego na ESP8266, które zlicza impulsy z wodomierzy i wysyła odczyty poprzez MQTT do serwera inteligentnego domu. Będziemy programować w mikropythonie korzystając z biblioteki uasyncio. Podczas tworzenia oprogramowania natknąłem się na kilka ciekawych trudności, które omówię również w tym artykule. Iść!

Schemat

Podłączenie wodomierza do inteligentnego domu

Sercem całego układu jest moduł na mikrokontrolerze ESP8266. Pierwotnie planowano ESP-12, ale mój okazał się wadliwy. Musieliśmy zadowolić się modułem ESP-07, który był dostępny. Na szczęście są one takie same zarówno pod względem pinów, jak i funkcjonalności, jedyną różnicą jest antena - ESP-12 ma wbudowaną, natomiast ESP-07 ma zewnętrzną. Jednak nawet bez anteny WiFi sygnał w mojej łazience odbierany jest normalnie.

Standardowe okablowanie modułu:

  • przycisk reset z podciągiem i kondensatorem (choć obydwa są już w module)
  • Sygnał zezwolenia (CH_PD) jest doprowadzany do zasilania
  • GPIO15 jest dociągany do ziemi. Jest to potrzebne tylko na początku, ale nadal nie mam się do czego przyczepić do tej nogi, już tego nie potrzebuję

Aby wprowadzić moduł w tryb firmware należy zewrzeć GPIO2 do masy, a dla wygody udostępniłem przycisk Boot. W normalnych warunkach ten pin jest doprowadzany do zasilania.

Stan linii GPIO2 sprawdzany jest tylko na początku pracy – po włączeniu zasilania lub bezpośrednio po resecie. Zatem moduł albo uruchamia się normalnie, albo przechodzi w tryb oprogramowania sprzętowego. Po załadowaniu tego pinu można używać jako zwykłego GPIO. Cóż, skoro jest tam już przycisk, można do niego dołączyć jakąś przydatną funkcję.

Do programowania i debugowania wykorzystam UART, który jest wyprowadzany na grzebień. Gdy zajdzie taka potrzeba, po prostu podłączam tam przejściówkę USB-UART. Trzeba tylko pamiętać, że moduł zasilany jest napięciem 3.3V. Jeżeli zapomnimy przełączyć zasilacz na to napięcie i podać 5V to moduł najprawdopodobniej się przepali.

Z prądem w łazience nie mam problemów - gniazdko jest jakieś metr od liczników, więc będę zasilany z 220V. Jako źródło prądu będę miał mały blok HLK-PM03 przez robota Tenstar. Osobiście nie radzę sobie z elektroniką analogową i energoelektroniką, ale tutaj mam gotowy zasilacz w małej obudowie.

Do sygnalizacji trybów pracy dostarczyłem diodę LED podłączoną do GPIO2. Jednak nie odlutowałem go, bo... Moduł ESP-07 posiada już diodę LED, dodatkowo jest podłączony do GPIO2. Ale niech będzie na płycie, na wypadek gdybym chciał wyprowadzić tę diodę LED do obudowy.

Przejdźmy do najciekawszej części. Wodomierze nie mają logiki, nie można ich zapytać o aktualne odczyty. Jedyne co nam dostępne to impulsy - zwieranie styków kontaktronu co litr. Moje wyjścia kontaktronu są podłączone do GPIO12/GPIO13. Programowo włączę rezystor podciągający wewnątrz modułu.

Początkowo zapomniałem podać rezystory R8 i R9 i moja wersja płytki ich nie posiada. Ponieważ jednak diagram już publikuję, aby każdy mógł go zobaczyć, warto skorygować to niedopatrzenie. Rezystory są potrzebne, żeby nie spalić portu, jeśli firmware zepsuje się i ustawi pin na jeden, a kontaktron zwiera tę linię do masy (z rezystorem popłynie maksymalnie 3.3 V/1000 Ohm = 3.3 mA).

Czas pomyśleć o tym, co zrobić, jeśli zabraknie prądu. Pierwsza opcja polega na zażądaniu na starcie początkowych wartości liczników z serwera. Wymagałoby to jednak znacznego skomplikowania protokołu wymiany. Ponadto wydajność urządzenia w tym przypadku zależy od stanu serwera. Jeżeli serwer nie uruchomiłby się po wyłączeniu zasilania (lub uruchomieniu później), wodomierz nie byłby w stanie zażądać wartości początkowych i nie działałby poprawnie.

Dlatego zdecydowałem się na wdrożenie zapisywania wartości liczników w chipie pamięci podłączonym przez I2C. Nie mam specjalnych wymagań co do wielkości pamięci flash - wystarczy zapisać 2 liczby (ilość litrów według liczników ciepłej i zimnej wody). Zrobi to nawet najmniejszy moduł. Trzeba jednak zwrócić uwagę na liczbę cykli nagrywania. Dla większości modułów jest to 100 tysięcy cykli, dla niektórych nawet milion.

Wydawać by się mogło, że milion to dużo. Ale w ciągu 4 lat mieszkania w moim mieszkaniu zużyłem nieco ponad 500 metrów sześciennych wody, czyli 500 tysięcy litrów! I 500 tysięcy rekordów we flashu. A to tylko zimna woda. Można oczywiście przelutowywać chip co kilka lat, ale okazuje się, że są chipy FRAM. Z programistycznego punktu widzenia jest to ta sama pamięć EEPROM I2C, tylko z bardzo dużą liczbą cykli przepisywania (setki milionów). Po prostu nadal nie mogę dostać się do sklepu z takimi mikroukładami, więc na razie pozostanie zwykły 24LC512.

Płytka drukowana

Początkowo planowałem zrobić tablicę w domu. Dlatego tablica została zaprojektowana jako jednostronna. Ale po spędzeniu godziny z żelazkiem laserowym i maską lutowniczą (bez niej jakoś nie da się tego zrobić), nadal zdecydowałem się zamówić deski u Chińczyków.

Podłączenie wodomierza do inteligentnego domu

Prawie przed zamówieniem płytki zorientowałem się, że oprócz chipa pamięci flash, do magistrali I2C mogę podłączyć jeszcze coś przydatnego, np. wyświetlacz. Pytanie, co dokładnie na niego wyprowadzić, wciąż pozostaje pytaniem, ale należy je skierować na płytkę. No cóż, skoro miałem zamiar zamawiać płytki z fabryki, to nie było sensu ograniczać się do płytki jednostronnej, więc linie I2C są jedynymi, które znajdują się z tyłu płytki.

Był też jeden duży problem z okablowaniem jednokierunkowym. Ponieważ Płytkę narysowano jednostronnie, więc po jednej stronie planowano umieścić tory i elementy SMD, a po drugiej elementy wyjściowe, złącza i zasilacz. Kiedy po miesiącu otrzymałem płytki zapomniałem o pierwotnym planie i przylutowałem wszystkie elementy z przodu. I dopiero przy lutowaniu zasilacza okazało się, że plus i minus zostały podłączone odwrotnie. Musiałem farmić ze skoczkami. Na powyższym obrazku zmieniłem już okablowanie, ale masa jest przenoszona z jednej części płytki na drugą poprzez kołki przycisku Boot (chociaż dałoby się narysować tor na drugiej warstwie).

Okazało się tak

Podłączenie wodomierza do inteligentnego domu

obudowa

Następnym krokiem jest ciało. Jeśli masz drukarkę 3D, nie stanowi to problemu. Nie przejmowałem się tym zbytnio - po prostu narysowałem pudełko odpowiedniego rozmiaru i zrobiłem wycięcia w odpowiednich miejscach. Pokrywa mocowana jest do korpusu za pomocą małych wkrętów samogwintujących.

Podłączenie wodomierza do inteligentnego domu

Wspomniałem już, że przycisk Boot może pełnić funkcję przycisku ogólnego przeznaczenia – dlatego wyświetlimy go na panelu przednim. Aby to zrobić, narysowałem specjalną „studnię”, w której znajduje się przycisk.

Podłączenie wodomierza do inteligentnego domu

Wewnątrz obudowy znajdują się również kołki, na których mocowana jest płytka i zabezpieczona pojedynczą śrubą M3 (na płytce nie było już miejsca)

Wyświetlacz wybrałem już podczas drukowania pierwszej przykładowej wersji etui. Standardowy dwuwierszowy czytnik nie zmieścił się w tej obudowie, za to na dole znalazł się wyświetlacz OLED SSD1306 128×32. Jest trochę mały, ale nie muszę się na niego codziennie gapić – to dla mnie za dużo.

Wymyślając w jaki sposób i jak poprowadzone zostaną z niego przewody, zdecydowałem się na umieszczenie wyświetlacza na środku obudowy. Ergonomia oczywiście pozostawia wiele do życzenia – przycisk znajduje się na górze, wyświetlacz na dole. Ale już pisałem, że pomysł podłączenia wyświetlacza przyszedł za późno i byłem zbyt leniwy, żeby przerabiać płytkę, żeby poruszyć przyciskiem.

Urządzenie jest zmontowane. Moduł wyświetlacza przykleja się do smarka za pomocą gorącego kleju

Podłączenie wodomierza do inteligentnego domu

Podłączenie wodomierza do inteligentnego domu

Efekt końcowy można zobaczyć na KDPV

Oprogramowanie układowe

Przejdźmy do części oprogramowania. W przypadku takich małych rzemiosł naprawdę lubię używać Pythona (mikropyton) - kod okazuje się bardzo zwarty i zrozumiały. Na szczęście nie trzeba schodzić do poziomu rejestru, aby wycisnąć mikrosekundy – wszystko można zrobić z poziomu Pythona.

Wydaje się, że wszystko jest proste, choć niezbyt proste – urządzenie posiada kilka niezależnych funkcji:

  • Użytkownik naciska przycisk i patrzy na wyświetlacz
  • Litry zaznaczają i aktualizują wartości w pamięci flash
  • Moduł monitoruje sygnał WiFi i w razie potrzeby ponownie się łączy
  • No cóż, bez mrugającej żarówki jest to niemożliwe

Nie można zakładać, że jedna funkcja nie działała, jeśli inna z jakiegoś powodu utknęła. Miałem już dość kaktusów w innych projektach i nadal widzę usterki w stylu „przegapiłem kolejny litr, ponieważ wyświetlacz był w tym momencie aktualizowany” lub „użytkownik nie może nic zrobić, gdy moduł łączy się z Wi-Fi.” Oczywiście niektóre rzeczy można wykonać za pomocą przerwań, ale możesz napotkać ograniczenia dotyczące czasu trwania, zagnieżdżania wywołań lub nieatomowych zmian zmiennych. Cóż, kod, który robi wszystko, szybko zamienia się w papkę.

В poważniejszy projekt Użyłem klasycznego wielozadaniowości z wywłaszczaniem i FreeRTOS, ale w tym przypadku model okazał się znacznie bardziej odpowiedni biblioteki coroutines i uasync . Co więcej, implementacja współprogramów w Pythonie jest po prostu niesamowita - wszystko odbywa się prosto i wygodnie dla programisty. Po prostu napisz własną logikę, po prostu powiedz mi, w których miejscach możesz przełączać się między strumieniami.

Sugeruję przestudiowanie różnic między wielozadaniowością z wywłaszczaniem a wielozadaniowością konkurencyjną jako przedmiot opcjonalny. Przejdźmy wreszcie 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 forever

Każdy licznik jest obsługiwany przez instancję klasy Counter. Przede wszystkim z pamięci EEPROM odejmowana jest początkowa wartość licznika (value_storage) – w ten sposób realizowane jest odzyskiwanie po awarii zasilania.

Pin jest inicjowany wbudowanym podciągnięciem do zasilacza: jeśli kontaktron jest zwarty, linia wynosi zero, jeśli linia jest otwarta, jest podciągany do zasilacza i sterownik odczytuje jedynkę.

Uruchamiane jest tu także osobne zadanie, które odpytuje pin. Każdy licznik wykona swoje własne zadanie. Oto jej kod

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

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

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

Do filtrowania odbić kontaktu potrzebne jest opóźnienie 25 ms, a jednocześnie reguluje to, jak często zadanie się budzi (kiedy to zadanie śpi, inne zadania są uruchomione). Co 25 ms funkcja się budzi, sprawdza pin i jeśli styki kontaktronu są zwarte, to przez licznik przepłynął kolejny litr i należy to przetworzyć.

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

        self._value_storage.write(self._value)

Przetworzenie kolejnego litra jest banalne – licznik po prostu rośnie. Cóż, byłoby miło zapisać nową wartość na dysku flash.

Aby ułatwić obsługę, dostępne są „akcesoria”.

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

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

Cóż, teraz skorzystajmy z uroków Pythona i biblioteki uasync i stwórzmy obiekt licznika, który będzie oczekiwał (jak możemy to przetłumaczyć na rosyjski? Ten, którego możesz się spodziewać?)

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

        return self.value()

    __iter__ = __await__  

Jest to na tyle wygodna funkcja, że ​​czeka aż wartość licznika zostanie zaktualizowana - funkcja co jakiś czas wybudza się i sprawdza flagę _value_changed. Fajną rzeczą w tej funkcji jest to, że kod wywołujący może zasnąć podczas wywoływania tej funkcji i spać do czasu otrzymania nowej wartości.

A co z przerwami?Tak, w tym momencie możesz mnie zastraszyć, mówiąc, że sam mówiłeś o przerwach, ale tak naprawdę zrobiłeś głupią ankietę. Właściwie przerwania to pierwsza rzecz, której próbowałem. W ESP8266 możesz zorganizować przerwanie brzegowe, a nawet napisać procedurę obsługi tego przerwania w Pythonie. W tym przerwaniu można zaktualizować wartość zmiennej. Prawdopodobnie wystarczyłoby, gdyby licznik był urządzeniem typu slave – takim, które czeka, aż zostanie zapytany o tę wartość.

Niestety (albo na szczęście?) moje urządzenie jest aktywne, samo musi wysyłać wiadomości poprzez protokół MQTT i zapisywać dane do EEPROM. I tu wchodzą w grę ograniczenia - nie można alokować pamięci w przerwaniach i używać dużego stosu, co oznacza, że ​​można zapomnieć o wysyłaniu wiadomości przez sieć. Istnieją bułki takie jak micropython.schedule(), które pozwalają uruchomić jakąś funkcję „tak szybko, jak to możliwe”, ale pojawia się pytanie: „o co chodzi?” A co jeśli właśnie teraz wysyłamy jakiś komunikat, a potem pojawia się przerwanie i psuje wartości zmiennych. Lub, na przykład, z serwera przyszła nowa wartość licznika, podczas gdy starej jeszcze nie zapisaliśmy. Generalnie trzeba zablokować synchronizację albo jakoś inaczej z niej wyjść.

I od czasu do czasu RuntimeError: zaplanuj awarie pełnego stosu i kto wie dlaczego?

Dzięki jawnemu odpytywaniu i uasync w tym przypadku okazuje się to piękniejsze i bardziej niezawodne

Przyniosłem pracę z pamięcią EEPROM na małą klasę

class EEPROM():
    i2c_addr = const(80)

    def __init__(self, i2c):
        self.i2c = i2c
        self.i2c_buf = bytearray(4) # Avoid creation/destruction of the buffer on each call


    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 trudno jest bezpośrednio pracować z bajtami, ale to bajty są zapisywane w pamięci. Musiałem zabezpieczyć konwersję między liczbami całkowitymi i bajtami za pomocą biblioteki ustruct.

Aby nie przenosić za każdym razem obiektu I2C i adresu komórki pamięci, zawinąłem to wszystko w mały i wygodny klasyk

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 tymi parametrami

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

Dochodzimy do najciekawszej części – realizacji komunikacji z serwerem poprzez MQTT. Cóż, nie ma potrzeby implementowania samego protokołu - znalazłem go w Internecie gotowa implementacja asynchroniczna. Tego właśnie użyjemy.

Wszystko, co najciekawsze, zebrane zostało w klasie CounterMQTTClient, która bazuje na bibliotece MQTTClient. Zacznijmy od peryferii

#####################################
# 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 możesz tworzyć i konfigurować piny i przyciski żarówek, a także obiekty liczników zimnej i ciepłej wody.

Przy inicjalizacji nie wszystko jest takie trywialne

    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 ustawienia parametrów pracy biblioteki mqtt_as wykorzystywany jest duży słownik różnych ustawień - config. Większość ustawień domyślnych jest dla nas w porządku, ale wiele ustawień należy ustawić jawnie. Aby nie zapisywać ustawień bezpośrednio w kodzie, przechowuję je w pliku tekstowym config.txt. Pozwala to na zmianę kodu niezależnie od ustawień, a także nitowanie kilku identycznych urządzeń o różnych parametrach.

Ostatni blok kodu uruchamia kilka współprogramów obsługujących różne funkcje systemu. Na przykład, oto współprogram obsługujący liczniki

    async def _counter_coro(self, counter, topic):
        # Publish initial value
        value = counter.value()
        await self.publish(topic, str(value))

        # Publish each new value
        while True:
            value = await counter
            await self.publish_msg(topic, str(value))

Współprogram czeka w pętli na nową wartość licznika i gdy tylko się ona pojawi, wysyła komunikat poprzez protokół MQTT. Pierwszy fragment kodu wysyła wartość początkową nawet jeśli przez licznik nie przepływa woda.

Klasa bazowa MQTTClient obsługuje się sama, inicjuje połączenie Wi-Fi i łączy się ponownie w przypadku utraty połączenia. Gdy nastąpi zmiana stanu połączenia WiFi, biblioteka informuje nas o tym wywołując funkcję wifi_connection_handler

    async def wifi_connection_handler(self, state):
        self.internet_outage = not state
        if state:
            self.dprint('WiFi is up.')
            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 is down.')
            
        await asyncio.sleep(0)

Funkcja jest uczciwie skopiowana z przykładów. W tym przypadku zlicza liczbę przerw w działaniu (internet_outages) i czas ich trwania. Po przywróceniu połączenia do serwera wysyłany jest czas bezczynności.

Nawiasem mówiąc, ostatnie uśpienie jest potrzebne tylko do tego, aby funkcja była asynchroniczna - w bibliotece jest wywoływana poprzez oczekiwanie i można wywoływać tylko funkcje, których treść zawiera inne oczekiwanie.

Oprócz połączenia się z Wi-Fi konieczne jest także nawiązanie połączenia z brokerem (serwerem) MQTT. Biblioteka również to robi i po nawiązaniu połączenia mamy możliwość zrobienia czegoś pożytecznego

    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 aktualnych wartości liczników poprzez wysłanie odpowiedniego komunikatu.

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

Funkcja ta przetwarza przychodzące wiadomości i w zależności od tematu (tytułu wiadomości) aktualizuje wartości jednego z liczników

Kilka funkcji pomocniczych

    # 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 wysyła wiadomość, jeśli połączenie zostało nawiązane. W przypadku braku połączenia komunikat zostanie zignorowany.

A to po prostu wygodna funkcja, która generuje i wysyła komunikaty debugujące.

    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 mrugnęliśmy diodą LED. Tutaj

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

Udostępniłem 2 tryby migania. Jeżeli połączenie zostanie utracone (lub dopiero będzie nawiązywane), urządzenie zacznie szybko migać. Jeżeli połączenie zostanie nawiązane, urządzenie będzie migać co 5 sekund. W razie potrzeby można tutaj zaimplementować inne tryby migania.

Ale dioda LED po prostu rozpieszcza. Skupiliśmy się także na wyświetlaczu.

    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)

Właśnie o tym mówiłem - jakie to proste i wygodne w przypadku współprogramów. Ta mała funkcja opisuje CAŁĄ obsługę użytkownika. Korutyna po prostu czeka na naciśnięcie przycisku i włącza wyświetlacz na 3 sekundy. Na wyświetlaczu widoczne są aktualne wskazania licznika.

Pozostało jeszcze kilka drobiazgów. Oto funkcja, która (ponownie) uruchamia całe przedsięwzięcie. Główna pętla wysyła różne informacje debugowania raz na minutę. Generalnie cytuję tak, jak jest – myślę, że nie ma potrzeby zbyt wiele 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)

Cóż, jeszcze kilka ustawień i stałych, aby uzupełnić opis

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

Wszystko zaczyna się w ten sposób

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

Coś się stało z moją pamięcią

Zatem cały kod jest tam. Pliki wgrałem za pomocą narzędzia ampy - pozwala ono na wgranie ich na wewnętrzny (ten w samym ESP-07) pendrive, a następnie dostęp do nich z poziomu programu jak do normalnych plików. Tam też przesłałem biblioteki mqtt_as, uasyncio, ssd1306 i kolekcje, których używałem (używane w mqtt_as).

Uruchamiamy i... Otrzymujemy MemoryError. Co więcej, im bardziej próbowałem zrozumieć, gdzie dokładnie dochodzi do wycieku pamięci, im więcej wydruków debugowania umieszczałem, tym wcześniej pojawiał się ten błąd. Krótkie wyszukiwanie w Google doprowadziło mnie do wniosku, że mikrokontroler ma w zasadzie tylko 30 kB pamięci, w której 65 kB kodu (wraz z bibliotekami) po prostu się nie zmieści.

Ale jest wyjście. Okazuje się, że micropython nie wykonuje kodu bezpośrednio z pliku .py - ten plik jest kompilowany jako pierwszy. Co więcej, jest on kompilowany bezpośrednio na mikrokontrolerze, zamieniany na kod bajtowy, który następnie jest zapisywany w pamięci. Otóż, aby kompilator działał, potrzebna jest także pewna ilość pamięci RAM.

Sztuka polega na tym, aby uchronić mikrokontroler przed kompilacją wymagającą dużych zasobów. Możesz skompilować pliki na dużym komputerze i wgrać gotowy kod bajtowy do mikrokontrolera. Aby to zrobić, musisz pobrać oprogramowanie układowe micropython i skompilować narzędzie mpy-cross.

Nie napisałem pliku Makefile, ale ręcznie przejrzałem i skompilowałem wszystkie niezbędne pliki (w tym biblioteki) mniej więcej w ten sposób

mpy-cross water_counter.py

Pozostaje tylko przesłać pliki z rozszerzeniem .mpy, nie zapominając najpierw usunąć odpowiedniego .py z systemu plików urządzenia.

Całość programowania wykonałem w programie (IDE?) ESPlorer. Umożliwia wgranie skryptów do mikrokontrolera i natychmiastowe ich wykonanie. W moim przypadku cała logika i tworzenie wszystkich obiektów znajduje się w pliku water_counter.py (.mpy). Ale żeby to wszystko wystartowało automatycznie, na początku musi też znajdować się plik o nazwie main.py. Co więcej, powinien to być dokładnie plik .py, a nie prekompilowany plik .mpy. Oto jego banalna zawartość

import water_counter

Uruchamiamy - wszystko działa. Jednak wolna pamięć jest niepokojąco mała – około 1kb. Mam jeszcze plany rozbudowy funkcjonalności urządzenia, a ten kilobajt to dla mnie zdecydowanie za mało. Okazało się jednak, że i w tym przypadku jest wyjście.

To jest ta rzecz. Mimo że pliki są kompilowane do kodu bajtowego i znajdują się w wewnętrznym systemie plików, w rzeczywistości są nadal ładowane do pamięci RAM i stamtąd wykonywane. Okazuje się jednak, że mikropython może wykonać kod bajtowy bezpośrednio z pamięci flash, ale w tym celu trzeba go wbudować bezpośrednio w oprogramowanie układowe. Nie jest to trudne, choć na moim netbooku zajęło mi to sporo czasu (tylko tam akurat miałem Linuksa).

Algorytm wygląda następująco:

  • Скачать i установить Otwórz SDK ESP. To coś składa kompilator i biblioteki dla programów dla ESP8266. Zmontowany zgodnie z instrukcją na stronie głównej projektu (wybrałem ustawienie STANDALONE=yes)
  • pobieranie sortowanie mikropytonów
  • Umieść wymagane biblioteki w portach/esp8266/modules wewnątrz drzewa micropython
  • Składamy firmware zgodnie z instrukcją zawartą w pliku ports/esp8266/README.md
  • Wgrywamy firmware do mikrokontrolera (robię to na Windowsie używając programów ESP8266Flasher lub esptool w Pythonie)

To wszystko, teraz „import ssd1306” pobierze kod bezpośrednio z oprogramowania układowego, a pamięć RAM nie będzie na to zużywana. Dzięki tej sztuczce załadowałem do oprogramowania sprzętowego tylko kod biblioteki, podczas gdy główny kod programu jest wykonywany z systemu plików. Dzięki temu można łatwo modyfikować program bez konieczności ponownej kompilacji oprogramowania. W tej chwili mam około 8.5kb wolnej pamięci RAM. Pozwoli nam to w przyszłości zaimplementować całkiem sporo różnych przydatnych funkcjonalności. Cóż, jeśli w ogóle nie ma wystarczającej ilości pamięci, możesz wcisnąć główny program do oprogramowania układowego.

Co zatem powinniśmy teraz z tym zrobić?

Ok, sprzęt przylutowany, firmware napisany, pudełko wydrukowane, urządzenie przyklejone do ściany i radośnie mruga żarówką. Ale na razie to wszystko czarna skrzynka (dosłownie i w przenośni) i nadal jest mało użyteczna. Czas coś zrobić z komunikatami MQTT wysyłanymi do serwera.

Mój „inteligentny dom” już działa System Majordoma. Moduł MQTT albo jest dostarczany od razu po wyjęciu z pudełka, albo można go łatwo zainstalować z rynku dodatków - nie pamiętam, skąd go wziąłem. MQTT nie jest rzeczą samowystarczalną - potrzebny jest tzw. broker - serwer odbierający, sortujący i przesyłający dalej komunikaty MQTT do klientów. Używam Mosquitto, które (podobnie jak majordomo) działa na tym samym netbooku.

Gdy urządzenie chociaż raz wyśle ​​wiadomość, wartość od razu pojawi się na liście.

Podłączenie wodomierza do inteligentnego domu

Wartości te można teraz powiązać z obiektami systemowymi, można je wykorzystać w skryptach automatyzacji i poddawać różnym analizom - to wszystko wykracza poza zakres tego artykułu. Wszystkim zainteresowanym mogę polecić system majordomo kanał Elektronika w obiektywie — znajomy też buduje inteligentny dom i wyraźnie mówi o skonfigurowaniu systemu.

Pokażę ci tylko kilka wykresów. To prosty wykres dziennych wartości

Podłączenie wodomierza do inteligentnego domu
Widać, że prawie nikt nie korzystał z wody w nocy. Kilka razy ktoś poszedł do toalety i wygląda na to, że filtr odwróconej osmozy zasysa kilka litrów na noc. Rano spożycie znacznie wzrasta. Ja zazwyczaj korzystam z wody z bojlera, jednak w pewnym momencie zachciało mi się wykąpać i chwilowo przełączyłem się na ciepłą wodę miejską – widać to również wyraźnie na dolnym wykresie.

Z tego wykresu dowiedziałem się, że na pójście do toalety potrzeba 6-7 litrów wody, wzięcie prysznica 20-30 litrów, umycie naczyń około 20 litrów, a kąpiel 160 litrów. Moja rodzina zużywa około 500-600 litrów dziennie.

Dla tych, którzy są szczególnie ciekawi, możesz przejrzeć zapisy dla każdej indywidualnej wartości

Podłączenie wodomierza do inteligentnego domu

Stąd dowiedziałem się, że przy odkręconym kranie woda płynie z prędkością około 1 litra na 5 sekund.

Jednak w tej formie statystyki prawdopodobnie nie są zbyt wygodne do przeglądania. Majordomo ma również możliwość przeglądania wykresów zużycia według dnia, tygodnia i miesiąca. Tutaj na przykład wykres zużycia w słupkach

Podłączenie wodomierza do inteligentnego domu

Na razie mam dane tylko z tygodnia. Za miesiąc ten wykres będzie bardziej orientacyjny – każdy dzień będzie miał osobną kolumnę. Obraz jest nieco psuty przez korekty wartości, które wprowadzam ręcznie (największa kolumna). I nie jest jeszcze jasne, czy źle ustawiłem pierwsze wartości, prawie o kostkę mniej, czy jest to błąd w oprogramowaniu i nie wszystkie litry zostały policzone. Potrzebuję więcej czasu.

Same wykresy wymagają jeszcze trochę magii, wybielenia, pomalowania. Być może zbuduję też wykres zużycia pamięci do celów debugowania - na wypadek, gdyby coś tam przeciekało. Być może jakoś pokażę okresy, kiedy nie było Internetu. Na razie to wszystko jest na poziomie pomysłów.

wniosek

Dziś moje mieszkanie stało się trochę mądrzejsze. Dzięki tak małemu urządzeniu wygodniej będzie mi monitorować zużycie wody w domu. Jeśli wcześniej oburzyłam się, że „znowu wypiliśmy mnóstwo wody w ciągu miesiąca”, teraz potrafię znaleźć źródło tego zużycia.

Niektórym może wydawać się dziwne patrzenie na odczyty na ekranie, jeśli jest on oddalony o metr od samego licznika. Ale w niedalekiej przyszłości planuję przeprowadzić się do innego mieszkania, gdzie będzie kilka pionów wodnych, a same liczniki najprawdopodobniej będą znajdować się na podeście. Bardzo przydatne będzie więc urządzenie do zdalnego odczytu.

Planuję także poszerzyć funkcjonalność urządzenia. Już rozglądam się za zaworami z napędem silnikowym. Teraz, aby przełączyć kocioł na wodę miejską, muszę odkręcić 3 krany w trudno dostępnej niszy. O wiele wygodniej byłoby to zrobić za pomocą jednego przycisku z odpowiednim oznaczeniem. No cóż, oczywiście, warto wdrożyć zabezpieczenie przed wyciekami.

W artykule opisałem moją wersję urządzenia opartego na ESP8266. Moim zdaniem wymyśliłem bardzo interesującą wersję oprogramowania mikropythona wykorzystującą współprogramy - prostą i ładną. Starałem się opisać wiele niuansów i niedociągnięć, które napotkałem podczas kampanii. Być może opisałem wszystko zbyt szczegółowo, osobiście, jako czytelnikowi, łatwiej mi jest pominąć niepotrzebne rzeczy, niż później przemyśleć to, co zostało niedopowiedziane.

Jak zawsze jestem otwarty na konstruktywną krytykę.

Kod źródłowy
Obwód i płytka
Model obudowy

Źródło: www.habr.com

Dodaj komentarz