Подключаем счетчик воды к умному дому

Когда-то системы домашней автоматизации, или как их часто называют «умный дом», были жутко дорогими и их могли позволить себе лишь богачи. Сегодня на рынке можно найти достаточно бюджетные комплекты с датчиками, кнопками/выключателями и исполнительными устройствами для управлением освещением, розетками, вентиляцией, водоснабжением и другими потребителями. И даже самый криворукий DIY-шник может приобщиться к прекрасному и за недорого собирать устройства для умного дома.

j22nlecz4t57-irpggnczt55wwe.jpeg

Как правило предлагаемые устройства это либо датчики, либо исполнительные механизмы. Они позволяют легко реализовать сценарии вроде «при срабатывании датчика движения включить свет» или «выключатель возле выхода тушит свет во всей квартире». Но вот с телеметрией как-то не сложилось. В лучшем случае это график температуры и влажности, или мгновенная мощность в конкретной розетке.

Недавно я себе поставил счетчики воды с импульсным выходом. Через каждый литр пробежавший через счетчик срабатывает геркон и замыкает контакт. Осталось дело за малым — прицепиться к проводам и попробовать из этого получить пользу. Например, анализировать потребление воды по часам и дням недели. Ну, а если стояков для воды в квартире несколько, то удобнее видеть все текущие показатели на одном экране, чем лазить по труднодоступным нишам с фонариком.

Под катом мой вариант устройства на базе ESP8266, которое считает импульсы со счетчиков воды и по MQTT отправляет показания на сервер умного дома. Программировать будем на micropython с использованием библиотеки uasyncio. При создании прошивки я наткнулся на несколько интересных сложностей, о которых также расскажу в этой статье. Поехали!

Схема

i-w2o7b8mfsommteri_mss88oa8.png

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

Обвязка модуля стандартная:

  • кнопка ресет с подтяжкой и конденсатором (хотя и то и другое уже есть внутри модуля)
  • Сигнал enable (CH_PD) подтянут к питанию
  • GPIO15 подтянут к земле. Это нужно только на старте, но мне все равно нечего на эту ногу цеплять больше не нужно


Для перевода модуля в режим прошивки нужно замкнуть GPIO2 на землю, а чтобы было удобнее я предусмотрел кнопку Boot. В нормальном состоянии этот пин подтягивается к питанию.

Состояние линии GPIO2 проверяется только вначале работы — при подаче питания или сразу после ресета. Так модуль либо загружается как обычно, либо переходит в режим прошивки. После загрузки этот вывод можно использовать как обычный GPIO. Ну, а раз там уже есть кнопка, то можно повесить на нее какую нибудь полезную функцию.

Для программирования и отладки я буду использовать UART, который вывел на гребенку. Когда нужно — я просто подключаю туда USB-UART переходник. Нужно только не забывать, что питается модуль от 3.3В. Если забыть переключить переходник на это напряжение и подать 5В, то модуль скорее всего сгорит.

С электричеством в ванной у меня проблем нет — розетка расположена примерно в метре от счетчиков, так что запитывать буду от 220В. В качестве источника питания у меня будет трудится небольшой блочок HLK-PM03 от Tenstar Robot. Лично у меня туго с аналоговой и силовой электроникой, а тут готовый блок питания в маленьком корпусе.

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

Переходим к самому интересному. У счетчиков воды нет никакой логики, у них нельзя спросить текущие показания. Единственное что нам доступно это импульсы — замыкание контактов геркона каждый литр. Выводы герконов у меня заведены в GPIO12/GPIO13. Подтягивающий резистор я буду включать программно внутри модуля.

Изначально я забыл предусмотреть резисторы R8 и R9 и в моем варианте платы их нет. Но раз я уже выкладываю схему на всеобщее обозрение, то стОит исправить эту оплошность. Резисторы нужны, чтобы не спалить порт в случае если прошивка глюканет и выставит единицу на пине, а геркон закоротит эту линию на землю (с резистором потечет максимум 3.3В/1000Ом = 3.3 мА).

Пора подумать что делать если пропадет электричество. Первый вариант — на старте запрашивать у сервера начальные значения счетчиков. Но это потребовало бы существенного усложнения протокола обмена. Более того, работоспособность устройства в таком случае зависит от состояния сервера. Если бы после отключения света сервер не завелся (или завелся позже), то счетчик воды не смог бы запросить начальные значения и работал бы неверно.

Поэтому я решил реализовать сохранение значений счетчиков в микросхеме памяти, подключенной по I2C. Особых требований по размеру флеш памяти у меня нет — нужно сохранять всего 2 числа (количество литров по счетчикам горячей и холодной воды). Даже самый маленький модуль подойдет. А вот на количество циклов записи нужно обратить внимание. У большинства модулей это 100 тыс циклов, у некоторых до миллиона.

Казалось бы миллион это много. Но я за 4 года проживания в своей квартире потребил чуть более 500 кубов воды, это 500 тыс литров! И 500 тыс записей во флеш. И это только холодная вода. Можно, конечно, перепаивать микросхему каждые пару лет, но оказалось есть микросхемы FRAM. С точки зрения программирования это тот же самый I2C EEPROM, только с ооооочень большим количеством циклов перезаписи (сотни миллионов). Вот только пока все никак не доеду до магазина с такими микросхемами, поэтому пока постоит обычная 24LC512.

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


Изначально я планировал делать плату в домашних условиях. Потому плата проектировалась как односторонняя. Но продолбавшись битый час с c лазерным утюгом и паяльной маской (без нее как-то не comme il faut), я все же решил заказать платы у китайцев.

ensirzhmczp3bs9n7a6a8plmzl4.png

Уже практически перед заказом платы я сообразил, что помимо микросхемы флеш памяти на шину I2C можно подцепить что нибудь еще полезное, например дисплей. Что именно на него выводить — пока еще вопрос, но развести на плате нужно. Ну, а раз я собрался платы заказывать на фабрике, то ограничивать себя односторонней платой уже не было смысла, поэтому линии на I2C — единственные на задней стороне платы.

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

Получилось вот так

au-dcsvpehh9p5g9iqyob5hjslu.jpeg

Копус


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

qqluzptanq4d24dsv4jdlb9uif8.png

Я уже упоминал, что кнопка Boot может быть использована как кнопка общего назначения — вот ее и выведем на переднюю панель. Для этого я нарисовал специальный «колодец» где живет кнопка.

dvxggnexv-fiqy0jqtptpdk1dvy.png

Внутри корпуса также располагаются пеньки, на которые устанавливается плата и фиксируется единственным винтом М3 (на плате больше места не оказалось)

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

Прикидывая и так и эдак как от него будут проложены провода решил прилепить дисплей посреди корпуса. Эргономика, конечно, ниже плинтуса — кнопка сверху, дисплей снизу. Но я уже говорил, что идея прикрутить дисплей пришла слишком поздно и лень было переразводить плату, чтобы переместить кнопку.

Устройство в сборе. Дисплейный модуль приклеен на сопли термоклей

snqrxfyyihzqwiwwwf-1yly545m.jpeg

owbh2oguwv4tiez2basnv11hgvw.jpeg

Конечный результат можно увидеть на КДПВ

Прошивка


Перейдем к программной части. Для вот таких небольших поделок мне очень нравится использовать язык Python (micropython)- код получается очень компактный и понятный. Благо тут нет необходимости спускаться на уровень регистров с целью выжимать микросекунды — все можно сделать из питона.

Вроде бы все просто, да не очень — в устройстве намечается несколько независимых функций:

  • Пользователь тыкает в кнопку и смотрит на дисплей
  • Литры тикают и обновляют значения во флеш памяти
  • Модуль следит за сигналом 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) вычитывается начальное значение счетчика — так реализуется восстановление после пропадания питания.

Пин инициализируется со встроенной подтяжкой к питания: если геркон замкнут — на линии ноль, если разомкнут линия подтягивается к питанию и контроллер читает единицу.

Также тут запускается отдельная задача, которая будет производить опрос пина. Каждый счетчик будет запускать свою задачу. Вот ее код

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


Задержка в 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


Ну, а теперь воспользуемся прелестями питона и библиотеки uasync и сделаем объект счетчика waitable (как это на русский перевести-то? Тот, которой можно ожидать?)

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

        return self.value()

    __iter__ = __await__  


Это такая удобная функция, которая ждет пока значение счетчика не обновится — функция время от времени просыпается и проверяет флажок _value_changed. Прикол этой функции в том, что вызывающий код может уснуть на вызове этой функции и спать до получения нового значения.

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

К сожалению (или к счастью?) мое устройство активное, оно должно само слать сообщения по протоколу 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) # 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("


В питоне напрямую с байтами работать сложновато, а ведь именно байты записываются в память. Пришлось городить конвертацию между целым числом и байтами с использованием библиотеки 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):
        # 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))


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

Базовый класс MQTTClient сам себя обслуживает, сам инициирует соединение по WiFi и переподключается когда соединение пропадает. При изменениях в состоянии соединения WiFi библиотека нас информирует вызовом 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)


Функция честно слизана из примеров. В данном случае она считает количество отключений (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("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))


Эта функция обрабатывает пришедшие сообщения, и в зависимости от темы (названия сообщения) обновляются значения одного из счетчиков

Парочка вспомогательных функций

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


Ну еще парочка настроек и констант для полноты описания

#####################################
# 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. Причем чем больше я пытался понять где именно утекает память, чем больше я расставлял дебаг принтов, тем раньше возникала эта ошибка. Короткий гуглеж привел меня к пониманию, что в микроконтроллере в принципе всего 30 кб памяти в которые 65 кб кода (вместе с библиотеками) ну никак не помещаются.

Но выход есть. Оказывается 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
  • Заливаем прошивку в микроконтроллер (я это делаю на винде программами ESP8266Flasher или питоновским esptool«ом)


Все, теперь 'import ssd1306' будет поднимать код напрямую из прошивки и оперативная память под это расходоваться не будет. Таким трюком я залил в прошивку только код библиотек, тогда как основной код программы у меня выполняется с файловой системы. Это позволяет легко модифицировать программу не перекомпилируя прошивку. На данный момент у меня свободно около 8.5 кб ОЗУ. Это позволит реализовать еще довольно много разного полезного функционала в будущем. Ну, а если памяти будет совсем не хватать, то можно и основную программу затолкать в прошивку.

И что с этим теперь делать?


Ок, железка спаяна, прошивка написана, коробка напечатана, устройство прилеплено на стену и радостно моргает лампочкой. Но пока это все черный ящик (в прямом и переносном смысле) и толку от него еще маловато. Пора что нибудь сделать с MQTT сообщениями, которые шлются на сервер.

Мой «умный дом» крутится на системе Majordomo. Модуль MQTT то ли есть из коробки, то ли легко устанавливается из маркета дополнений — уже не помню откуда он у меня взялся. MQTT штука не самодостаточная — нужен т.н. брокер — сервер, который принимает, сортирует и перенаправляет клиентам MQTT сообщения. Я использую mosquitto, который (как и majordomo) крутится все на том же нетбуке.

После того, как устройство хоть раз отправит сообщение значение тут же появится в списке.

hfipdb1coxh_lsaaz600mi-39y0.png

Эти значения теперь можно связать с объектами системы, их можно использовать в сценариях автоматизации и подвергать различному анализу — все это out of scope этой статьи. Кому интересна система majordomo могу порекомендовать канал Электроника В Объективе — товарищ тоже строит умный дом и доходчиво рассказывает про настройку системы.

Покажу лишь пару графиков. Это простой график значений за сутки

kmhwxruql1fk0kmykb1gpcpci_0.png
Видно, что ночью водой почти никто не пользовался. Пару раз кто-то сходил в туалет, и, похоже фильтр обратного осмоса пару литров за ночь посасывает. Утром потребление существенно возрастает. Обычно я пользуюсь водой из бойлера, но тут я захотел принять ванную и временно переключил на городскую горячую воду — это также хорошо заметно на нижнем графике.

Из этого графика я узнал, что сходить в туалет это 6–7л воды, принять душ — 20–30л, помыть посуду около 20л, а чтобы принять ванную нужно 160л. За день моя семья потребляет где-то около 500–600л.

Для особо любознательных можно заглянуть в записи по каждому отдельному значению

8l8isu_aiaswajdeeuexytpwffi.png

Отсюда я узнал что при открытом кране вода течет со скоростью примерно 1л за 5с.

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

m3wasfzbvxb2tz1yhemod2ewwdy.png

Пока у меня данных только за неделю. Через месяц этот график будет более показательным — каждому дню будет соответствовать отдельный столбик. Немного картину портят корректировки значений, которые я ввожу вручную (самый большой столбик). И пока не ясно, то ли я неправильно задал самые первые значения почти на куб меньше, то ли это баг в прошивке и не все литры пошли в зачет. Нужно больше времени.

Над самими графиками еще поколдовать нужно, побелить, покрасить. Возможно я также буду в отладочных целях строить график потребления памяти — вдруг там что-то утекает. Возможно буду как-то отображать периоды, когда отсутствовал интернет. Пока все это крутится на уровне идеи.

Заключение

Сегодня моя квартира стала чуточку умнее. С таким небольшим устройством мне будет удобнее следить за потреблением воды в доме. Если раньше я возмущался «опять много воды за месяц потребили», то теперь я смогу найти источник этого потребления.

Кому-то покажется странным смотреть показания на экране, если он в метре от самого счетчика. Но в не очень отдаленном будущем я планирую переселиться в другую квартиру, где будет несколько стояков воды, а сами счетчики, скорее всего, будут расположены на лестничной площадке. Так что устройство удаленного снятия показаний будет весьма кстати.

Функциональность устройства я тоже планирую расширять. Я уже присматриваюсь к моторизованным вентилям. Сейчас для переключения бойлер-городская вода мне нужно поворачивать 3 крана в труднодоступной нише. Было бы гораздо удобнее делать это одной кнопкой с соответствующей индикацией. Ну и, само собой, защиту от протечек реализовать стОит.

В статье я рассказал свой вариант устройства на базе ESP8266. На мой взгляд у меня получился весьма интересный вариант прошивки на micropython с использованием корутин — просто и симпатично. Я постарался описать множество нюансов и косяков, с которыми столкнулся походу. Возможно я слишком детально все описывал, лично мне как читателю проще промотать лишнее, чем потом додумывать то, что было недосказано.

Как всегда я открыт для конструктивной критики.

Исходный код: github.com/grafalex82/WaterMeter
Схема и плата: easyeda.com/editor#id=|22b1dc7469f045d1a97160dd76647c4c|1b87dd112b7949cfb40f412b40f98e09
Модель корпуса: cad.onshape.com/documents/5f3dc0e54cb4b2a42d2d7384/v/9ec48c3fd374e797db5aece7/e/e4a4facc97d991164f090cc5

© Geektimes