Свързваме водомера с умния дом

Преди време системите за домашна автоматизация, или както често ги наричат "умен дом", бяха ужасно скъпи и само богатите можеха да си ги позволят. Днес на пазара могат да се намерят съвсем бюджетни комплекти с сензори, бутони/ключове и изпълнителни устройства за управление на осветлението, контакти, вентилация, водоснабдяване и други потребители. Дори и най-неумелият DIY ентусиаст може да се впусне в прекрасното и да събира устройства за умен дом на достъпна цена.

Свързваме водомера с умния дом

Обикновено предлаганите устройства са или сензори, или изпълнителни механизми. Те позволяват лесно реализиране на сценарии като "при срабатывании на сензора за движение да се включи светлината" или "ключът до изхода да гаси светлината в целия апартамент". Но с телеметрията нещата не са се получили добре. Най-добрият случай е графика на температурата и влажността или моментната мощност в конкретен контакт.

Наскоро инсталирах водомери с импулсен изход. При всеки изминал литър през водомера сработва геркон и затваря контакта. Остава само да се свържа с проводниците и да опитам да извлека полза от това. Например, да анализирам потреблението на вода по часове и дни от седмицата. А ако в апартамента има няколко вертикали за вода, е по-удобно да видя всички текущи показания на един екран, отколкото да се ровя по труднодостъпни ниши с фенерче.

Под заглавието е моят вариант на устройство на базата на ESP8266, което отчита импулсите от водомерите и по MQTT изпраща показанията на сървъра на умния дом. Ще програмираме на micropython с използване на библиотеката uasyncio. При създаването на фърмуера се натъкнах на няколко интересни трудности, за които също ще разкажа в тази статия. Да започваме!

Схема

Свързваме водомера с умния дом

Сърцето на цялата схема е модулът на микроконтролерът ESP8266. Първоначално беше планиран ESP-12, но моят се оказа бракован. Трябваше да се задоволя с модула ESP-07, който беше на разположение. За щастие те са идентични както по изводи, така и по функционалност, разликата е само в антената — при ESP-12 тя е вградена, а при ESP-07 — външна. Въпреки това дори без антена WiFi сигналът в моята баня се приема нормално.

Връзката на модула е стандартна:

  • бутон за ресет с подтягане и кондензатор (въпреки че и двете вече са вътре в модула)
  • Сигналът enable (CH_PD) е свързан към захранването
  • GPIO15 е свързан към земята. Това е необходимо само при стартиране, но всъщност нямам какво друго да свържа към този пин.

За да превключите модула в режим на флаширане, трябва да свържете GPIO2 към земята, а за по-удобно предвидих бутон Boot. В нормалното си състояние този пин е свързан към захранването.

Състоянието на линия GPIO2 се проверява само в началото на работата — при подаване на захранване или веднага след нулиране. По този начин модулът или се зарежда обичайно, или преминава в режим на флаширане. След зареждане този извод може да се използва като обикновен GPIO. А щом вече има бутон, може да свържа някаква полезна функция към него.

За програмиране и отстраняване на грешки ще използвам UART, който е изведен на шина. Когато е нужно, просто свързвам USB-UART адаптер. Трябва само да не забравя, че модулът се захранва от 3.3V. Ако забравя да превключа адаптера на това напрежение и подам 5V, модулът вероятно ще изгори.

С електричеството в банята нямам проблеми — контактът е разположен на около метър от измервателните уреди, така че ще се захранвам от 220V. Като източник на захранване ще използвам малък блок HLK-PM03 от Tenstar Robot. Лично аз не съм много опитен в аналоговата и силовата електроника, а тук имам готов захранващ блок в малък корпус.

За сигнализация на режимите на работа предвидих светодиод, свързан към GPIO2. Все пак не го запоих, тъй като в модула ESP-07 вече има светодиод, свързан към същия GPIO2. Но на платката нека да остане — в случай че искам да изведа този светодиод на корпуса.

Преминаваме към най-интригуващото. При водомерите няма никаква логика, не можем да запитаме за текущите показания. Единственото, което е на разположение, са импулсите — замикане на контактите на геркона за всеки литър. Изводите на герконите са свързани с GPIO12/GPIO13. Поддържащият резистор ще включа програмно вътре в модула.

Първоначално забравих да предвидя резисторите R8 и R9 и в моя вариант на платката те липсват. Но тъй като вече публикувам схемата за обществено достояние, е добре да коригирам тази грешка. Резисторите са необходими, за да не повредят порта, в случай че фърмуера глючи и постави единица на пина, а герконът замкне тази линия към земята (с резистора ще протече максимум 3.3V/1000Ω = 3.3mA).

Време е да помислим какво да правим, ако електричеството изчезне. Първият вариант е по време на стартиране да се искат начални стойности на измервателите от сървъра. Но това би изисквало значително усложняване на протокола за обмен. Освен това, работоспособността на устройството в такъв случай зависи от състоянието на сървъра. Ако след прекъсването на тока сървърът не стартира (или стартира по-късно), измервателят на водата не би могъл да поиска началните стойности и ще работи неправилно.

Затова реших да реализирам запазване на стойностите на измервателите в микросхема памет, свързана по I2C. Нямам специални изисквания относно размера на флаш паметта — необходимо е да се запазят само 2 числа (броят литри по измервателите за гореща и студена вода). Дори най-малкият модул би свършил работа. Но трябва да се обърне внимание на броя на цикли на запис. При повечето модули това са 100 хиляди цикъла, при някои дори до милион.

На пръв поглед милион е много. Но през 4-те години, през които живея в апартамента си, съм употребил малко над 500 кубични метра вода, което е 500 хиляди литра! И 500 хиляди записа във флаш паметта. И това е само студената вода. Разбира се, мога да сменям микросхемата на всеки две години, но се оказа, че има микросхеми FRAM. От гледна точка на програмирането, това е същият I2C EEPROM, само с много по-голям брой цикли на презапис (стотици милиони). Само че все още не съм успял да отида до магазина с такива микросхеми, затова засега ще използвам обикновената 24LC512.

Печатна платка

Първоначално планирах да направя платката в домашни условия. Затова платката беше проектирана като едностранна. Но прекарах един час, опитвайки се с лазерна ютия и паялна маска (без нея не е много коректно), и все пак реших да поръчам платки от китайците.

Свързваме водомера с умния дом

Вече почти преди да поръчам платките, осъзнах, че освен микросхемата за флаш памет, към шината I2C мога да свържа още нещо полезно, например дисплей. Какво точно да показвам на него — все още е въпрос, но трябва да се проектира на платката. А тъй като реших да поръчам платките от фабриката, нямаше смисъл да се ограничавам до едностранна платка, затова линиите на I2C са единствените на задната страна на платката.

С едностранна разводка беше свързан един голям проблем. Тъй като платката беше проектирана едностранно, се планираше разполагането на трасетата и SMD компонентите от едната страна, а изводните компоненти, конекторите и блока за захранване от другата. Когато след месец получих платките, забравих за първоначалния план и запоях всички компоненти от лицевата страна. И само когато дойде време за запояване на блока за захранване, разбрах, че плюс и минус са обърнати. Пришло се да се правят прехвърляния. На картинката по-горе вече промених разводката, но земята преминава от една част на платката в друга чрез изводите на бутона Boot (макар че можеше да се проведе трасето и на втория слой).

Получението изглежда така

Свързваме водомера с умния дом

Корпус

Следваща стъпка — корпус. При наличие на 3D принтер това не е проблем. Особено не се затруднявах — просто начертах кутия с нужния размер и направих изрези на нужните места. Капакът се прикрепя към корпуса с малки винтове.

Свързваме водомера с умния дом

Вече споменах, че бутонът Boot може да бъде използван като бутон с общо назначение — ето, че ще го изведем на предната панел. За това нарисувах специален "колодец", където живее бутонът.

Свързваме водомера с умния дом

Вътре в корпуса също се разполагат пънчета, на които се монтира платката и се фиксира с един единствен винт М3 (на платката няма повече място).

Дисплеят подбрах, след като напечатах първия пробен вариант на корпуса. Стандартният двустрочен дисплей не се побираше в корпуса, затова в наличието открих OLED дисплей SSD1306 128×32. Малко е, но не ми се налага да го гледам всеки ден — става.

Обмисляйки как и накъде да бъдат прокарани кабелите, реших да разместя дисплея в средата на корпуса. Ергономиката, разбира се, е под всякаква критика — бутонът е отгоре, дисплеят отдолу. Но вече споменах, че идеята за прикрепяне на дисплея дойде твърде късно и не ми се беше искало да реорганизирам платката, за да преместя бутона.

Устройството в сглобен вид. Дисплейният модул е залепен с термолепило.

Свързваме водомера с умния дом

Свързваме водомера с умния дом

Крайният резултат може да се види на КДПВ.

Фърмуер

Преминаваме към софтуерната част. За такива малки проекти много ми харесва да използвам езика Python (micropython)- кодът става много компактен и разбираем. За щастие, тук няма нужда да се спускаме на нивото на регистрите, за да изкараме микросекунди — всичко може да се направи от Python.

Изглежда всичко е просто, но не е така – устройството предвижда няколко независими функции:

  • Потребителят натиска бутона и наблюдава дисплея.
  • Литрите се отчитат и обновяват стойностите във флаш паметта.
  • Модулът следи за WiFi сигнала и при нужда се презвързва.
  • Без мигаща лампичка изобщо не може да стане.

Невъзможно е да стане така, че една функция да не работи, ако друга по някаква причина не е в ред. Вече имам достатъчно опит с проблеми в други проекти и виждам какви бъгове се появяват в стил "пропуснат е литър, защото по това време се обновяваше дисплеят" или "потребителят не може да направи нищо, докато модулът се свързва с WiFi". Разбира се, някои неща могат да се правят чрез прекъсвания, но може да се сблъскаме с ограничения по продължителност, вложеност на повиквания или неатомарно изменение на променливи. И кодът, който се опитва да се справи с всичко наведнъж, бързо става неразбория.

В проект, който е по-сериозен. Използвал съм класическа преодолима многозадачност и FreeRTOS, но в този случай моделът на съпрограми (coroutines) и библиотека uasync . Освен това, питоновата реализация на корутините е невероятна – за програмиста всичко е направено просто и удобно. Просто напишете логиката си и само посочете къде може да се превключва между потоковете.

Разликите между преодолима и конкурентна многозадачност предлагам да се проучат допълнително. А сега нека, най-накрая, да преминем към кода.

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

Всеки брояч се обработва от екземпляр на класа Counter. На първо място, от EEPROM (value_storage) се извлича началната стойност на брояча – така се реализира възстановяване след прекъсване на захранването.

Пинът се инициализира с вградена подтяжка към захранване: ако херкона е затворен – на линията е нула, ако е отворен, линията се подтягва към захранването и контролерът чете единица.

Също така тук се стартира отделна задача, която ще извършва опитване на пина. Всеки брояч ще стартира своя собствена задача. Ето кода й:

    """ Проверка на пина и увеличение на стойността, когато е преминал още един литър """
    async def _switchcheck(self):
        last_checked_pin_state = self._pin.value()  # Вземи началното състояние

        # Проверявай за промяна на състоянието на пина
        while True:
            state = self._pin.value()
            if state != last_checked_pin_state:
                # Състоянието е сменено: реагирай веднага
                last_checked_pin_state = state
                if state == 0:
                    self._another_litre_passed()

            # Игнорирай нататъшни промени в състоянието, докато ключът не се установи
            await asyncio.sleep_ms(Counter.debounce_ms)

Забавяне от 25мс е необходимо за филтриране на дребезга в контактите и същевременно регулира колко често задачата се активира (докато задачата спи, работят други задачи). На всеки 25мс функцията се активира, проверява пина и ако контактите на херкулите са замкнати, това означава, че е преминал нов литър и трябва да бъде обработен.

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

        self._value_storage.write(self._value)

Обработката на поредния литър е тривиална — просто увеличаваме брояча. И новата стойност е добре да бъде записана на флаш памет.

За удобство на потребителя са предвидени 'достъпници'

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

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

Сега ще се възползваме от предимствата на Python и библиотеката uasync и ще направим обекта на брояча 'waitable' (как да го преведем на български? Този, който може да бъде изчакван?)

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

        return self.value()

    __iter__ = __await__  

Това е много удобна функция, която чака докато стойността на брояча не се обнови — функцията периодично се активира и проверява флага _value_changed. Интересното при тази функция е, че извикващият код може да заспи при извикването на тази функция и да спи до получаването на нова стойност.

А какво ще стане с прекъсванията?Да, в този момент можете да ме разигравате, като кажете, че сам казах за прекъсванията, а всъщност организирах прост опит за запитване на пина. Всъщност, прекъсванията са първото, което опитах. В ESP8266 може да се организира прекъсване при фронта и дори да се напише обработчик за това прекъсване на Python. В това прекъсване може да се обнови стойността на променливата. Вероятно това би било достатъчно, ако броячът е ведомо устройство — такова, което чака, докато не го попитат за стойността.

За съжаление (или късмет?) моето устройство е активно, то трябва само да изпраща съобщения по протокола MQTT и да записва данни в EEPROM. И тук вече настъпват ограничения — в прекъсванията не може да се заделят памет и да се използва голям стек, така че можем да забравим за изпращането на съобщения по мрежата. Има плюшки като micropython.schedule(), които позволяват да се стартира някаква функция "когато и да е", но възниква въпросът "каква е ползата?". Понякога изпращаме някакво съобщение, а тук се намесва прекъсване и разваля стойностите на променливите. Или, например, новото значение на брояча пристига от сървъра, преди да сме написали старото. В общи линии, трябва да се организира синхронизация или да се търси друг начин.

А понякога излиза RuntimeError: schedule stack full и няма кой да знае защо?

С явното опрашване и uasync в този случай става по-красиво и надеждно.

Работата с EEPROM я изнесох в малък клас.

class EEPROM():
    i2c_addr = const(80)

    def __init__(self, i2c):
        self.i2c = i2c
        self.i2c_buf = bytearray(4) # Избягване на създаване/унищожаване на буфера при всяко извикване


    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)

В питон директно работата с байтове е сложна, а всъщност точно байтовете се записват в паметта. Пришло се налага да правя конверсия между цяло число и байтове, използвайки библиотеката ustruct.

За да не предавам всеки път обекта I2C и адреса на паметта, всичко това увих в малък и удобен клас.

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)

Самият обект I2C се създава с такива параметри.

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

Пристъпваме към най-интересното — реализацията на комуникация със сървера по MQTT. А самият протокол не е нужно да се реализира — в интернет открихме готова асинхронна реализация.. Ето я и ще я използваме.

Всичко най-интересно е събрано в класа CounterMQTTClient, който е базиран на библиотечния MQTTClient. Нека започнем с периферията.

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

Тук се създават и настройват пиновете на лампичката и бутона, както и обектите на броячи на студена и гореща вода.

С инициализацията не всичко е толкова тривиално

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

За задаване на параметрите на библиотеката mqtt_as се използва голям речник с разнообразни настройки — config. Повечето настройки по подразбиране ни подхождат, но много настройки трябва да бъдат зададени явно. За да не пиша настройките директно в кода, ги съхранявам в текстовия файл config.txt. Това позволява промяна на кода независимо от настройките, както и създаване на няколко идентични устройства с различни параметри.

Последният блок код стартира няколко корутини за обслужване на различни функции на системата. Ето например корутина, която обслужва счетчиците

    async def _counter_coro(self, counter, topic):
        # Публикуване на начална стойност
        value = counter.value()
        await self.publish(topic, str(value))

        # Публикуване на всяка нова стойност
        while True:
            value = await counter
            await self.publish_msg(topic, str(value))

Корутина в цикъл очаква нова стойност на счетчика и веднага щом тя пристигне — изпраща съобщение по протокола MQTT. Първият кодов фрагмент изпраща началната стойност дори когато водата не тече през счетчика.

Базовият клас MQTTClient самостоятелно се обслужва, сам инициира свързването по WiFi и се преосъединява, когато връзката се загуби. При промени в състоянието на връзката WiFi, библиотеката ни информира с извикване на wifi_connection_handler

    async def wifi_connection_handler(self, state):
        self.internet_outage = not state
        if state:
            self.dprint('WiFi е активен.')
            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 е неактивен.')
            
        await asyncio.sleep(0)

Функцията е взета от примери. В този случай тя брои броя на прекъсванията (internet_outages) и тяхната продължителност. Когато връзката бъде възстановена, на сървъра се изпраща времето на неработоспособност.

Между другото, последният sleep е необходим само за да направи функцията асинхронна — в библиотеката тя се извиква чрез await, а така могат да се извикват само функции, в тялото на които има друг await.

Освен свързването с WiFi, е необходимо също и свързване с MQTT брокера (сървъра). Това също се осъществява от библиотеката, а на нас ни остава шансът да направим нещо полезно, когато връзката е установена.

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

Тук подписваме се на няколко съобщения — сървърът сега има възможност да зададе текущите стойности на измервателите, изпращайки съответното съобщение.

    def mqtt_msg_handler(self, topic, msg):
        topicstr = str(topic, 'utf8')
        self.dprint("Получено 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))

Тази функция обработва получените съобщения, и в зависимост от темата (имущество на съобщението) се актуализират стойностите на един от измервателите.

Няколко помощни функции

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

Тази функция изпраща съобщение в случай, че връзката е установена. Ако връзка няма — съобщението се игнорира.

А това е просто удобна функция, която формулира и изпраща отладъчни съобщения.

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

Толкова много текст, а ние все още не сме мигнали светодиода. Ето го.

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

Предвидил съм 2 режима на мигане. Ако връзката е изгубена (или само се установява), устройството ще мига бързо. Ако връзката е установена — устройството мига веднъж на 5 секунди. При необходимост може да се реализират и други режими на мигане.

Но светодиодът е просто играчка. Все пак се насочихме и към дисплея.

    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)

Ето това, за което говорех — колко е лесно и удобно с корутините. Тази малка функция описва ВСИЧКИ взаимодействия с потребителя. Корутина просто чака натискането на бутон и включва дисплея за 3 секунди. На дисплея се показват текущите показания на броячите.

Остана още няколко детайла. Ето функцията, която пре(з)пуска всичко това. Основният цикъл се занимава само с изпращането на различна отладъчна информация веднъж в минута. В общи линии, давам го такъв, какъвто е — не мисля, че е необходимо да коментирам особено.

   async def main(self):
        while True:
            try:
                await self._connect_to_WiFi()
                await self._run_main_loop()
                    
            except Exception as e:
                self.dprint('Глобална комуникационна неуспех: ', e)
                await asyncio.sleep(20)

    async def _connect_to_WiFi(self):
        self.dprint('Свързване с WiFi и 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('Свързано!')
        self.internet_outage = False

    async def _run_main_loop(self):
        # Цикъл безкрай
        mins = 0
        while True:
            gc.collect()  # За RAM статистика.
            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("Възникна изключение: ", e)
            mins += 1

            await asyncio.sleep(60)

Още няколко настройки и константи за пълнота на описанието.

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

Всичко това се стартира така:

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

Нещо стана с моята памет.

И така, целият код е наличен. Файловете качвах с помощта на утилитата ampy — тя позволява да бъдат качвани на вътрешната (тази, която в самия ESP-07) флаш памет и след това да се достъпват от програмата като обикновени файлове. Тук също качих библиотеките, които използвам: mqtt_as, uasyncio, ssd1306 и collections (използва се в mqtt_as).

Стартираме и… Получаваме MemoryError. В интересното е, че колкото повече се опитвах да разбера къде точно изтича паметта, толкова по-рано възникваше тази грешка. Краткото търсене в Google ме насочи към разбирането, че в микроконтролера има само 30kB памет, в която 65kB код (включително библиотеките) просто не могат да се поберат.

Но има решение. Оказва се, че micropython не изпълнява кода директно от .py файла — този файл първо се компилира. И той се компилира направо на микроконтролера, превръща се в байткод, който впоследствие се съхранява в паметта. За работата на компилятора е необходим и определен обем оперативна памет.

Трикът е да се избавим от ресурсно интензивната компилация на микроконтролера. Можем да компилираме файловете на мощен компютър, а в микроконтролера да заредим готовия байткод. За целта трябва да свалим фърмуера на micropython и да го съберем. утилита mpy-cross.

Не написах Makefile, а ръчно минах и компилирах всички нужни файлове (включително библиотеки) по следния начин:

mpy-cross water_counter.py

Остава само да заредим файловете с разширение .mpy, като не забравим предварително да изтрием съответните .py от файловата система на устройството.

Цялата разработка водих в програмата (IDE?) ESPlorer. Тя позволява да се зареждат скриптове в микроконтролера и веднага да се изпълняват. В моя случай цялата логика и създаване на всички обекти се намират в файла water_counter.py (.mpy). Но за да се стартират автоматично при стартиране, трябва да има и файл с име main.py. И то трябва да е именно .py, а не предварително компилиран .mpy. Ето неговото тривиално съдържание:

import water_counter

Стартираме — всичко работи. Но свободната памет е заплашително малко — около 1Кб. Имам още планове за разширение на функционалността на устройството, и този килобайт явно ще ми е недостатъчен. Но се оказа, че и за този случай има решение.

Става следното. Дори и когато файловете са компилирани в байткод и се намират на вътрешната файлова система, всъщност те все пак се зареждат в оперативната памет и се изпълняват от там. Но се оказва, че micropython може да изпълнява байткод направо от флаш паметта, но за целта трябва да бъде вграден директно във фърмуера. Това не е сложно, макар на моя нетбук да отне значително време (защото там ми се оказа, че имам линукс).

Алгоритъмът е следният:

  • Свалете и инсталирайте ESP Open SDK. Тази неща събира компилатора и библиотеките за програми под ESP8266. Събира се по инструкциите на главната страница на проекта (аз избрах инсталация STANDALONE=yes).
  • Изтегли източниците на micropython
  • Необходими библиотеки да поставите в ports/esp8266/modules в дървото на micropython.
  • Събиране на фърмуера според инструкциите в файла ports/esp8266/README.md
  • Качираме фърмуер в микроконтролера (правя това под Windows с програми ESP8266Flasher или питоновия esptool)

Всичко, сега ‘import ssd1306’ ще вдига кода директно от фърмуера и оперативната памет за това няма да се използва. С този трик качих в фърмуера само кода на библиотеките, докато основният код на програмата се изпълнява от файловата система. Това позволява лесна модификация на програмата без да преподменям фърмуера. В момента имам свободно около 8.5kb RAM. Това ще позволи реализацията на още доста полезен функционал в бъдеще. Ако паметта не стига, може и основната програма да влезе във фърмуера.

И какво да правим сега?

Ок, хардуерът е споен, фърмуерът е написан, кутията е отпечатана, устройството е залепено на стената и радостно мига с лампичка. Но докато това все още е черна кутия (в пряк и преносен смисъл) и ползата от него е малка. Време е да направим нещо с MQTT съобщенията, които се изпращат на сървъра.

Моят “умен дом” функционира на системата Majordomo. Модулът MQTT или го има в кутията, или лесно се инсталира от магазина за добавки — вече не помня откъде ми се е паднал. MQTT е не самодостатъчна система — необходим е т.н. брокер — сървър, който приема, подрежда и пренасочва MQTT съобщенията на клиентите. Използвам mosquitto, който (както и majordomo) работи на същия нетбук.

След като устройството изпрати съобщение поне веднъж, стойността веднага ще се появи в списъка.

Свързваме водомера с умния дом

Тези стойности сега могат да се свържат с обектите в системата, могат да се използват в сценарии за автоматизация и да се подлагат на различен анализ — всичко това е извън обхвата на тази статия. За тези, които се интересуват от системата majordomo, мога да препоръчам канала Електроника В Обектива — приятел също строи умен дом и обяснява подробно за настроението на системата.

Ще покажа само два графика. Това е прост график на стойностите за деня

Свързваме водомера с умния дом
Ясно е, че през нощта почти никой не е ползвал вода. Няколко пъти някой е ходил до тоалетната и, изглежда, филтърът за обратно осмозиране изтегля няколко литра през нощта. Сутрешното потребление значително нараства. Обикновено използвам вода от бойлера, но тук реших да се изкъпя и временно преминах на градска гореща вода — това също е добре видно на долния график.

От тази графика разбрах, че за посещение на тоалетната са необходими 6-7 л вода, за душ — 20-30 л, за измиване на съдове около 20 л, а за вана са нужни 160 л. Семейството ми изразходва около 500-600 л на ден.

За особено любопитните можете да погледнете записите за всяка отделна стойност.

Свързваме водомера с умния дом

Оттук научих, че при отворен кран водата тече с приблизителна скорост 1 л за 5 секунди.

Но в този вид статистиката вероятно не е много удобна за гледане. В majordomo има и други възможности за разглеждане на графици на потребление по дни, седмици и месеци. Например, ето графика на потреблението в колони.

Свързваме водомера с умния дом

Засега имам данни само за седмица. След месец този график ще бъде по-показателен — на всеки ден ще съответства отделна колона. Малко картината се разваля от корекциите на стойностите, които въвеждам ръчно (най-голямата колона). И засега не е ясно дали съм задал неправилно първоначалните стойности почти с куб по-малко, или е бъг в фърмуера и не всички литри са отчетени. Нуждае се от повече време.

Още трябва да поработя над самите графици, да ги боядисам и осветя. Може би също ще изградя график за потребление на паметта с цел отстраняване на проблеми — кой знае, дали нещо изтича. Може би ще покажа периодите, когато интернетът е отсъствал. Засега всичко това е на ниво идея.

Заключение

Днес моят дом стана малко по-умен. С това малко устройство ще ми бъде по-удобно да следя за потреблението на вода в къщата. Ако преди бях недоволен, че сме изразходили много вода през месеца, сега ще мога да открия източника на това потребление.

На някой може да му се стори странно да гледа показанията на екрана, ако е на метър от самия измервател. Но в не много далечно бъдеще планирам да се преместя в друга квартира, където ще има няколко вертикални водопровода, а самите измерватели вероятно ще са разположени на стълбището. Така че устройството за дистанционно отчитане на показания ще бъде много удобно.

Също така планирам да разширя функционалността на устройството. Вече се интересувам от моторизирани вентилни кранове. Сега за превключване между бойлера и градската вода трябва да завъртя 3 крана в трудно достъпна ниша. Щеше да бъде много по-удобно да го правя с един бутон с подходяща индикация. И, разбира се, си струва да реализирам защита от протечки.

В статията представих моя вариант на устройство с ESP8266. Според мен, успях да сътворя доста интересна версия на фърмуер за micropython с използване на корутини — просто и приятно. Опитах се да опиша множество нюанси и проблеми, с които се сблъсках по пътя. Може би описвах всичко прекалено подробно; на мен ми е по-лесно да прегледам излишната информация, отколкото да се опитвам да се досетя за неща, които не са били споменати.

Както винаги, съм отворен за конструктивна критика.

Изходен код
Схема и платка
Модел на корпуса

Източник: habr.com

Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри 🔥 Купете надежден хостинг за сайтове със защита от DDoS, VPS и VDS сървъри | ProHoster