Un tempo i sistemi di automazione domestica, o come vengono spesso chiamati 'casa intelligente', erano estremamente costosi e potevano permetterseli solo i ricchi. Oggi sul mercato si possono trovare kit abbastanza economici con sensori, pulsanti/interruttori e dispositivi attuatori per controllare l'illuminazione, le prese, la ventilazione, l'acqua e altri consumatori. E anche il più inesperto degli appassionati di fai-da-te può avvicinarsi al meraviglioso mondo della domotica e assemblare dispositivi per la casa intelligente a costi contenuti.

In genere i dispositivi offerti sono sensori o attuatori. Questi consentono di implementare facilmente scenari come 'quando il sensore di movimento si attiva accendi la luce' o 'l'interruttore vicino all'uscita spegne la luce in tutta la casa'. Ma per quanto riguarda la telemetria, le cose non sono andate altrettanto bene. Nel miglior dei casi, si tratta di un grafico di temperatura e umidità, o della potenza istantanea in una specifica presa.
Di recente ho installato contatori d'acqua con uscita a impulsi. Ogni litro che passa attraverso il contatore attiva un reed switch che chiude il contatto. Ora rimane solo collegarsi ai fili e cercare di trarne vantaggio. Ad esempio, analizzando il consumo d'acqua per ore e giorni della settimana. E se ci sono più colonne dell'acqua nell'appartamento, è più comodo vedere tutte le letture attuali su un unico schermo piuttosto che cercare in nicchie difficili da raggiungere con una torcia.
Di seguito presento la mia versione di un dispositivo basato su ESP8266, che conta gli impulsi dai contatori dell'acqua e invia le letture al server della casa intelligente tramite MQTT. Programmeremo in micropython utilizzando la libreria uasyncio. Creando il firmware ho incontrato alcune difficoltà interessanti, di cui parlerò anche in questo articolo. Iniziamo!
Schema

Il cuore dell'intero schema è un modulo basato sul microcontrollore ESP8266. Inizialmente era previsto un ESP-12, ma il mio era difettoso. Ho dovuto accontentarmi del modulo ESP-07, che era disponibile. Fortunatamente, sono identici sia nei pin che nelle funzionalità; l'unica differenza è nell'antenna: l'ESP-12 ha un'antenna integrata, mentre l'ESP-07 ha un'antenna esterna. Tuttavia, anche senza antenna, il segnale WiFi nella mia stanza da bagno è buono.
Il cablaggio del modulo è standard:
- pulsante di reset con pull-up e condensatore (anche se entrambe queste componenti sono già presenti all'interno del modulo)
- Il segnale enable (CH_PD) è collegato all'alimentazione
- GPIO15 è collegato a terra. Questo è necessario solo all'avvio, ma non ho più niente da collegare a quel pin.
Per mettere il modulo in modalità di programmazione, è necessario collegare GPIO2 a terra, e per facilitare la cosa ho previsto un pulsante Boot. In condizioni normali, questo pin è collegato all'alimentazione.
Lo stato della linea GPIO2 viene controllato solo all'inizio del funzionamento — quando si fornisce l'alimentazione o subito dopo il reset. In questo modo, il modulo si avvia normalmente o passa alla modalità di programmazione. Dopo l'avvio, questo pin può essere utilizzato come un normale GPIO. E visto che c'è già un pulsante, posso collegarlo a qualche funzione utile.
Per la programmazione e il debug utilizzerò il UART, che ho portato sulla scheda di sviluppo. Quando serve, ci collego semplicemente un adattatore USB-UART. Bisogna solo ricordarsi che il modulo funziona a 3.3V. Se dimentico di impostare l'adattatore su questa tensione e fornisco 5V, il modulo probabilmente si brucerà.
Non ho problemi con l'elettricità in bagno: la presa è posizionata a circa un metro dai contatori, quindi alimenterò da 220V. Come fonte di alimentazione utilizzerò un piccolo di Tenstar Robot. Personalmente ho difficoltà con l'elettronica analogica e di potenza, mentre qui c'è un alimentatore pronto in un piccolo case.
Per segnalare le modalità di funzionamento, ho previsto un LED collegato a GPIO2. Tuttavia, non l'ho saldatu perché nel modulo ESP-07 c'è già un LED, collegato allo stesso GPIO2. Ma sulla scheda lasciamolo — potrebbe essere utile per portare questo LED all'esterno.
Passiamo alla parte più interessante. I contatori dell'acqua non hanno alcuna logica, non è possibile chiedere le letture attuali. L'unica cosa a nostra disposizione sono gli impulsi — chiusura dei contatti del relè ogni litro. I contatti del relè sono collegati a GPIO12/GPIO13. Il resistente pull-up lo attiverò programmaticamente all'interno del modulo.
Inizialmente avevo dimenticato di prevedere i resistori R8 e R9 e nel mio progetto non ci sono. Ma poiché sto già pubblicando lo schema per la pubblica visione, vale la pena correggere questa svista. I resistori sono necessari per non bruciare la porta nel caso in cui il firmware non funzioni correttamente e imposti un'unità sul pin, mentre il relè cortocircuita questa linea a terra (con il resistore fluirà al massimo 3.3V/1000Ω = 3.3mA).
È tempo di pensare a cosa fare se salta la corrente. La prima opzione è richiedere al server i valori iniziali dei contatori all'avvio. Ma questo richiederebbe un notevole complicamento del protocollo di scambio. Inoltre, in questo caso, la funzionalità del dispositivo dipenderebbe dallo stato del server. Se, dopo un'interruzione della corrente, il server non si avviasse (o si avviasse più tardi), il contatore dell'acqua non sarebbe in grado di richiedere i valori iniziali e funzionerebbe in modo errato.
Perciò ho deciso di implementare il salvataggio dei valori dei contatori in un chip di memoria collegato tramite I2C. Non ho requisiti particolari per la dimensione della memoria flash: è necessario memorizzare solo 2 numeri (il numero di litri secondo i contatori dell'acqua calda e fredda). Anche il modulo più piccolo andrà bene. Ma bisogna prestare attenzione al numero di cicli di scrittura. La maggior parte dei moduli ha 100.000 cicli, alcuni arrivano fino a un milione.
A prima vista, un milione sembra tanto. Ma in quattro anni di soggiorno nel mio appartamento ho consumato poco più di 500 metri cubi d'acqua, ovvero 500.000 litri! E 500.000 scritture nella flash. E questo è solo per l'acqua fredda. Posso anche cambiare il chip ogni paio d'anni, ma ho scoperto che ci sono chip FRAM. In termini di programmazione, sono praticamente gli stessi chip EEPROM I2C, ma con un numero di cicli di riscrittura molto, molto elevato (centinaia di milioni). Solo che finora non sono riuscito a raggiungere un negozio che li abbia, quindi per ora utilizzerò il solito 24LC512.
Scheda stampata
Inizialmente avevo pianificato di realizzare la scheda a casa. Perciò, la scheda è stata progettata come unilaterale. Ma dopo aver passato un'ora a faticare con il ferro da stiro laser e il mascheramento di saldatura (senza di esso non è proprio all'altezza), ho deciso di ordinare le schede dai cinesi.

Poco prima di ordinare la scheda, ho realizzato che oltre al chip di memoria flash, sulla linea I2C si possono collegare altre cose utili, come ad esempio un display. Cosa visualizzare su di esso è ancora un punto interrogativo, ma bisogna predisporre le tracce sulla scheda. E poiché ho deciso di ordinare le schede in fabbrica, non aveva più senso limitarsi a una scheda unilaterale, quindi le linee I2C sono le uniche sul retro della scheda.
Un errore grande era anche legato alla progettazione unilaterale. Poiché il circuito stampato era progettato da un lato, le tracce e i componenti SMD dovevano essere posizionati da un lato, mentre i componenti di uscita, le connessioni e l'alimentazione dall'altro. Quando un mese dopo ho ricevuto i circuiti, ero dimenticato del piano originale e ho saldato tutti i componenti sulla faccia anteriore. E solo quando è arrivato il momento di saldare l'alimentatore mi sono reso conto che il positivo e il negativo erano invertiti. Ho dovuto improvvisare con dei ponticelli. Nella figura sopra ho già cambiato il layout, ma la massa passa da una parte del circuito all'altra attraverso i terminali del pulsante di avvio (anche se si sarebbe potuto condurre la traccia sul secondo strato).
È venuto così

Scocca
Il passo successivo è il cabinet. Con una stampante 3D non è un problema. Non mi sono complicato troppo — ho semplicemente disegnato una scatola della dimensione necessaria e ho fatto i tagli nei posti giusti. Il coperchio è fissato al cabinet con piccole viti autofilettanti.

Ho già accennato che il pulsante di avvio può essere utilizzato come pulsante universale — quindi lo porteremo sul pannello frontale. Per questo ho disegnato un apposito "pozzetto" dove si trova il pulsante.

All'interno del cabinet ci sono anche dei supporti, su cui viene montato il circuito e fissato con un'unica vite M3 (non c'era più spazio sulla scheda).
Lo schermo l'ho selezionato dopo aver stampato la prima versione campione del cabinet. Un normale display a due righe non entrava in questo cabinet, ma ho trovato un display OLED SSD1306 128×32. È un po' piccolo, ma non ho bisogno di guardarlo ogni giorno — va bene.
Considerando come sarebbero stati posati i fili, ho deciso di incollare lo schermo al centro del cabinet. L'ergonomia, ovviamente, è pessima — il pulsante è in alto, lo schermo in basso. Ma ho già detto che l'idea di aggiungere lo schermo è arrivata troppo tardi e non avevo voglia di riprogettare la scheda per spostare il pulsante.
Dispositivo assemblato. Il modulo dello schermo è incollato con colla a caldo.


Il risultato finale è visibile su KDVP
Firmware
Passiamo alla parte software. Per queste piccole creazioni mi piace molto utilizzare il linguaggio Python () — il codice risulta molto compatto e comprensibile. Per fortuna non c'è bisogno di scendere al livello dei registri per spremere microsecondi — tutto può essere fatto in Python.
Sembra tutto semplice, eppure non lo è: nell'apparecchio sono previste diverse funzioni indipendenti.
- L'utente preme il pulsante e guarda il display.
- I litri vengono registrati e aggiornano i valori nella memoria flash.
- Il modulo monitora il segnale WiFi e si riconnette se necessario.
- Ebbene, senza una lampadina lampeggiante non si può fare.
Non si può permettere che una funzione non funzioni, mentre un'altra per qualche motivo è lenta. Ho già mangiato troppi cactus in altri progetti e ora vedo chiaramente i glitch in stile “abbiamo saltato un litro, perché in quel momento si stava aggiornando il display” o “l'utente non può fare nulla mentre il modulo si connette al WiFi”. Certo, alcune cose possono essere gestite tramite interruzioni, ma si può incorrere in limiti di durata, nidificazione delle chiamate o modifiche non atomiche delle variabili. E il codice che si occupa di tutto e subito diventa rapidamente inestricabile.
In Ho usato il classico multitasking preemptive e FreeRTOS, ma in questo caso il modello più adatto si è rivelato essere Inoltre, l'implementazione delle coroutine in Python è semplicemente fantastica: per il programmatore tutto è progettato per essere semplice e pratico. Basta scrivere la logica e indicare dove è possibile passare tra i thread.
Le differenze tra multitasking preemptive e concorrente le consiglio di studiare come facoltativa. E ora, finalmente, passiamo al codice.
#####################################
# 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 foreverOgni contatore è gestito da un'istanza della classe Counter. Per prima cosa, il valore iniziale del contatore viene letto dalla EEPROM (value_storage) realizzando così il ripristino dopo un'interruzione di corrente.
Il pin viene inizializzato con un pull-up interno: se il reed switch è chiuso, sulla linea c'è uno zero, se è aperto, la linea viene tirata verso l'alimentazione e il controller legge un uno.
Viene anche avviato un compito separato che effettuerà la scansione del pin. Ogni contatore avvierà il proprio compito. Ecco il suo codice.
""" Interroga il pin e aumenta il valore quando passa un altro litro """
async def _switchcheck(self):
last_checked_pin_state = self._pin.value() # Ottieni lo stato iniziale
# Polling per un cambiamento del pin
while True:
state = self._pin.value()
if state != last_checked_pin_state:
# Lo stato è cambiato: agisci immediatamente.
last_checked_pin_state = state
if state == 0:
self._another_litre_passed()
# Ignora ulteriori cambiamenti di stato fino a quando l'interruttore si stabilizza
await asyncio.sleep_ms(Counter.debounce_ms)Un ritardo di 25 ms è necessario per filtrare il ronzio dei contatti e regola anche la frequenza con cui il task si sveglia (mentre questo task dorme, gli altri task sono in esecuzione). Ogni 25 ms la funzione si sveglia, controlla il pin e se i contatti del relè si sono chiusi, significa che attraverso il contatore è passato un altro litro e questo deve essere elaborato.
def _another_litre_passed(self):
self._value += 1
self._value_changed = True
self._value_storage.write(self._value)L'elaborazione di un altro litro è triviale: si incrementa semplicemente il contatore. E sarebbe utile scrivere il nuovo valore sulla flash.
Per facilitare l'uso, sono previsti "accessori".
def value(self):
self._value_changed = False
return self._value
def set_value(self, value):
self._value = value
self._value_changed = FalseE ora approfittiamo delle meraviglie di Python e della libreria uasync e rendiamo l'oggetto contatore 'attendibile' (come si traduce in russo? Quello di cui si può aspettare?).
def __await__(self):
while not self._value_changed:
yield from asyncio.sleep(0)
return self.value()
__iter__ = __await__ È una funzione comoda che attende fino a quando il valore del contatore non si aggiorna: la funzione si sveglia di tanto in tanto e controlla il flag _value_changed. Il vantaggio di questa funzione è che il codice chiamante può addormentarsi nella chiamata di questa funzione e rimanere in attesa fino a ricevere un nuovo valore.
E come stanno le interruzioni?Sì, in questo punto potete farmi un po' di trolling, dicendo che ho appena parlato di interruzioni, mentre in realtà ho impostato un semplice polling del pin. In realtà, le interruzioni sono state la prima cosa che ho provato. Nell'ESP8266 è possibile impostare un'interruzione sul fronte, e persino scrivere un gestore per questa interruzione in Python. In questa interruzione è possibile aggiornare il valore della variabile. Probabilmente, sarebbe sufficiente se il contatore fosse un dispositivo slave—un dispositivo che aspetta finché non gli viene chiesto quel valore.
Sfortunatamente (o fortunatamente?) il mio dispositivo è attivo, deve inviare autonomamente messaggi tramite il protocollo MQTT e registrare dati nella EEPROM. Qui entrano in gioco le limitazioni: nelle interruzioni non è possibile allocare memoria e utilizzare uno stack grande, il che significa che possiamo dimenticarci di inviare messaggi sulla rete. Ci sono vantaggi come micropython.schedule(), che consentono di eseguire una funzione “non appena possibile”, ma sorge la domanda “e a che serve?”. Potremmo inviare un messaggio proprio ora, e un'interruzione interferisce e rovina i valori delle variabili. Oppure, ad esempio, il server invia un nuovo valore del contatore mentre non abbiamo ancora registrato quello vecchio. In sostanza, dobbiamo implementare una sincronizzazione o trovare un altro modo.
E ogni tanto si verifica l'errore RuntimeError: schedule stack full e chissà perché?
Con polling esplicito e uasync, in questo caso è tutto un po' più bello e affidabile.
Ho estratto il lavoro con la EEPROM in una piccola classe.
class EEPROM():
i2c_addr = const(80)
def __init__(self, i2c):
self.i2c = i2c
self.i2c_buf = bytearray(4) # Evita la creazione/distruzione del buffer a ogni chiamata
def read(self, eeprom_addr):
self.i2c.readfrom_mem_into(self.i2c_addr, eeprom_addr, self.i2c_buf, addrsize=16)
return ustruct.unpack_from("<I", self.i2c_buf)[0]
def write(self, eeprom_addr, value):
ustruct.pack_into("<I", self.i2c_buf, 0, value)
self.i2c.writeto_mem(self.i2c_addr, eeprom_addr, self.i2c_buf, addrsize=16)In Python è complicato lavorare direttamente con i byte, eppure sono proprio i byte a essere scritti in memoria. Ho dovuto implementare una conversione tra numeri interi e byte utilizzando la libreria ustruct.
Per non dover passare ogni volta l'oggetto I2C e l'indirizzo della cella di memoria, ho racchiuso tutto in una piccola e comoda classe.
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)L'oggetto I2C viene creato con questi parametri.
i2c = I2C(freq=400000, scl=Pin(5), sda=Pin(4))Arriviamo alla parte più interessante: l'implementazione della comunicazione con il server tramite MQTT. Non è necessario implementare il protocollo stesso — ho trovato . La utilizzeremo.
Tutto il succo è raccolto nella classe CounterMQTTClient, che si basa sulla libreria MQTTClient. Iniziamo con le periferiche.
#####################################
# 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))Qui vengono creati e configurati i pin della lampadina e del pulsante, così come gli oggetti per i contatori di acqua fredda e calda.
Con l'inizializzazione non è tutto così scontato
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())Per impostare i parametri di funzionamento della libreria mqtt_as si utilizza un ampio dizionario di varie configurazioni — config. La maggior parte delle impostazioni predefinite ci si adatta, ma molte devono essere specificate esplicitamente. Per evitare di scrivere le configurazioni direttamente nel codice, le conservo in un file di testo config.txt. Questo permette di modificare il codice indipendentemente dalle impostazioni e di creare diversi dispositivi identici con parametri differenti.
L'ultimo blocco di codice avvia diverse coroutines per gestire varie funzioni del sistema. Ecco ad esempio una coroutine che si occupa dei contatori
async def _counter_coro(self, counter, topic):
# Pubblica il valore iniziale
value = counter.value()
await self.publish(topic, str(value))
# Pubblica ogni nuovo valore
while True:
value = await counter
await self.publish_msg(topic, str(value))La coroutine attende in un ciclo un nuovo valore del contatore e appena è disponibile — invia un messaggio tramite il protocollo MQTT. Il primo pezzo di codice invia il valore iniziale anche se l'acqua non scorre attraverso il contatore.
La classe base MQTTClient si gestisce autonomamente, inizia da sola la connessione WiFi e si riconnette quando la connessione viene a mancare. Quando ci sono cambiamenti nello stato della connessione WiFi, la libreria ci informa tramite la chiamata a wifi_connection_handler
async def wifi_connection_handler(self, state):
self.internet_outage = not state
if state:
self.dprint('Il WiFi è attivo.')
duration = ticks_diff(ticks_ms(), self.internet_outage_start) // 1000
await self.publish_debug_msg('RiconnessoDopo', duration)
else:
self.internet_outages += 1
self.internet_outage_start = ticks_ms()
self.dprint('Il WiFi è inattivo.')
await asyncio.sleep(0)La funzione è onestamente copiata dagli esempi. In questo caso, conta il numero di interruzioni (internet_outages) e la loro durata. Quando la connessione viene ripristinata, viene inviato al server il tempo di inattività.
A proposito, l'ultimo sleep è necessario solo per rendere la funzione asincrona: nella libreria viene richiamata tramite await, mentre le funzioni possono essere chiamate solo se contengono un altro await.
Oltre a connettersi al WiFi, è necessario stabilire una connessione con il broker MQTT (server). Questo è compito della libreria, e noi abbiamo l'opportunità di fare qualcosa di utile una volta che la connessione è stabilita.
async def mqtt_connection_handler(self, client):
await client.subscribe(self._mqtt_cold_water_theme)
await client.subscribe(self._mqtt_hot_water_theme)Qui ci iscriviamo a diversi messaggi: il server ora ha la possibilità di impostare i valori attuali dei contatori inviando il relativo messaggio.
def mqtt_msg_handler(self, topic, msg):
topicstr = str(topic, 'utf8')
self.dprint("Messaggio MQTT ricevuto 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))Questa funzione gestisce i messaggi ricevuti e, a seconda del tema (nome del messaggio), aggiorna i valori di uno dei contatori.
Un paio di funzioni ausiliarie.
# 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")Questa funzione si occupa di inviare un messaggio se la connessione è stabilita. Se non c'è connessione, il messaggio viene ignorato.
E questa è semplicemente una funzione comoda che crea e invia messaggi di debug.
async def publish_debug_msg(self, subtopic, msg):
await self.publish_msg("{}\/{}/".format(self._mqtt_debug_water_theme, subtopic), str(msg))
Tanto testo, e ancora non abbiamo fatto lampeggiare il LED. Ecco qui.
# 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)Ho previsto 2 modalità di lampeggiamento. Se la connessione è persa (o si sta solo stabilendo), il dispositivo lampeggerà velocemente. Se la connessione è stabilita, il dispositivo lampeggerà ogni 5 secondi. Se necessario, qui è possibile implementare anche altre modalità di lampeggiamento.
Ma il LED è solo un gioco. Abbiamo ancora intenzione di utilizzare il display.
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)Questo è ciò di cui parlavo: quanto sia semplice e comodo lavorare con le coroutine. Questa piccola funzione descrive TUTTA l'interazione con l'utente. La coroutina aspetta semplicemente che venga premuto un pulsante e accende il display per 3 secondi. Sul display vengono mostrati i valori attuali dei contatori.
Rimangono ancora un paio di dettagli. Ecco la funzione che riavvia tutto. Il ciclo principale si occupa solo di inviare vari dati di debug ogni minuto. In generale, lo presento così com'è: non c'è molto da commentare, credo.
async def main(self):
while True:
try:
await self._connect_to_WiFi()
await self._run_main_loop()
except Exception as e:
self.dprint('Errore globale di comunicazione: ', e)
await asyncio.sleep(20)
async def _connect_to_WiFi(self):
self.dprint('Connessione a WiFi e 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('Connesso!')
self.internet_outage = False
async def _run_main_loop(self):
# Ciclo infinito
mins = 0
while True:
gc.collect() # Per le statistiche della 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("Si è verificata un'eccezione: ", e)
mins += 1
await asyncio.sleep(60)Bene, ci sono ancora un paio di configurazioni e costanti per completezza della descrizione.
#####################################
# 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)Si avvia tutto così:
client = CounterMQTTClient()
loop = asyncio.get_event_loop()
loop.run_until_complete(client.main())Qualcosa è cambiato nella mia memoria.
Quindi, c'è tutto il codice. Ho caricato i file usando l'utilità ampy — questa permette di caricarli sulla memoria interna (quella presente nell'ESP-07) e poi accederli dal programma come a normali file. Ho caricato anche le librerie che utilizzo, mqtt_as, uasyncio, ssd1306 e collections (usata all'interno di mqtt_as).
Avviamo e… otteniamo un MemoryError. E più tentavo di capire dove esattamente si perdeva la memoria, più presto si manifestava questo errore. Una breve ricerca su Google mi ha portato a comprendere che nel microcontrollore ci sono solo 30 kB di memoria, in cui 65 kB di codice (insieme alle librerie) non possono assolutamente entrarci.
Ma c'è una soluzione. A quanto pare micropython non esegue il codice direttamente dal file .py: questo file viene prima compilato. Inoltre, viene compilato direttamente sul microcontrollore, trasformandosi in bytecode, che poi viene memorizzato in memoria. E per far funzionare il compilatore è necessario anche un certo quantitativo di RAM.
Il trucco consiste nel liberare il microcontrollore dalla compilazione ad alta intensità di risorse. È possibile compilare i file su un grande computer e caricare sul microcontrollore già il bytecode pronto. Per fare ciò, è necessario scaricare il firmware micropython e compilarlo. .
Non ho scritto un Makefile, ma ho manualmente passato in rassegna e compilato tutti i file necessari (compresi le librerie) più o meno in questo modo:
mpy-cross water_counter.pyRimane solo da caricare i file con estensione .mpy, ricordando di rimuovere precedentemente i corrispondenti .py dal file system del dispositivo.
Ho svolto tutto lo sviluppo nel programma (IDE?) ESPlorer. Consente di caricare script sul microcontrollore ed eseguirli immediatamente. Nel mio caso, tutta la logica e la creazione di tutti gli oggetti si trovano all'interno del file water_counter.py (.mpy). Ma affinché tutto ciò venga eseguito automaticamente all'avvio, deve esserci anche un file chiamato main.py. Deve trattarsi specificamente di un .py, e non di un .mpy precompilato. Ecco il suo contenuto triviale:
import water_counterAvviamo — tutto funziona. Ma la memoria disponibile è minacciosamente poca — circa 1KB. Ho ancora piani per espandere la funzionalità del dispositivo, e questo kilobyte chiaramente sarà troppo poco. Ma anche per questa situazione c'è una soluzione.
Il punto è questo. Anche se i file sono stati compilati in bytecode e si trovano nel file system interno, in pratica vengono comunque caricati nella memoria RAM ed eseguiti da lì. Ma sembra che micropython possa eseguire bytecode direttamente dalla memoria flash, ma per farlo deve essere incorporato direttamente nel firmware. Non è difficile, anche se sul mio netbook ha preso un bel po' di tempo (lì ho trovato Linux).
L'algoritmo è il seguente:
- Scarica e installa . Questa cosa compila il compilatore e le librerie per i programmi per ESP8266. Viene compilato secondo le istruzioni sulla pagina principale del progetto (ho scelto l'installazione STANDALONE=yes)
- Scarica
- Carica le librerie necessarie in ports/esp8266/modules all'interno della struttura di micropython
- Compila il firmware secondo le istruzioni nel file
- Carichiamo il firmware nel microcontrollore (lo faccio su Windows con i programmi ESP8266Flasher o con lo strumento esptool di Python)
Tutto qui, ora ‘import ssd1306’ solleverà il codice direttamente dal firmware e la memoria RAM non verrà utilizzata per questo. Con questo trucco ho caricato nel firmware solo il codice delle librerie, mentre il codice principale del programma viene eseguito dal file system. Questo permette di modificare facilmente il programma senza ricompilare il firmware. Attualmente ho circa 8,5 kB di RAM liberi. Questo consente di implementare ancora molte funzionalità utili in futuro. Se la memoria dovesse risultare insufficiente, posso anche mettere il programma principale nel firmware.
E adesso cosa farne?
Ok, l'hardware è saldato, il firmware è scritto, la scatola è stampata, il dispositivo è attaccato al muro e lampeggia gioiosamente. Ma per ora è ancora una scatola nera (in senso sia letterale che figurato) e l'utile è ancora piuttosto scarso. È ora di fare qualcosa con i messaggi MQTT che vengono inviati al server.
La mia “smart home” gira su . Il modulo MQTT è presente di default o si installa facilmente dal marketplace degli addon — non ricordo più da dove sia spuntato. MQTT non è autonomo — serve un broker, ovvero un server che riceve, ordina e inoltra i messaggi MQTT ai client. Io uso mosquitto, che (come majordomo) gira tutto sullo stesso netbook.
Dopo che il dispositivo invierà un messaggio anche solo una volta, il valore apparirà subito nella lista.

Questi valori ora possono essere collegati agli oggetti del sistema, possono essere utilizzati negli scenari di automazione e analizzati in vari modi — tutto ciò è al di fuori dello scopo di questo articolo. Per chi è interessato al sistema majordomo, posso raccomandare — un compagno che sta costruendo anch'egli una smart home e spiega chiaramente come configurare il sistema.
Mostrerò solo un paio di grafici. Questo è un semplice grafico dei valori su base giornaliera

Si vede che di notte l'acqua non è stata praticamente usata. Qualche volta qualcuno è andato in bagno e, a quanto pare, il filtro ad osmosi inversa assorbe un paio di litri durante la notte. La mattina, il consumo aumenta notevolmente. Di solito uso acqua dal boiler, ma qui ho voluto fare un bagno e ho temporaneamente commutato sull'acqua calda di rete — questo è anche ben visibile nel grafico in basso.
Da questo grafico ho scoperto che andare in bagno consuma 6-7 litri d'acqua, fare una doccia richiede 20-30 litri, lavare i piatti circa 20 litri, mentre per fare un bagno servono 160 litri. In un giorno, la mia famiglia consuma circa 500-600 litri.
Per coloro particolarmente curiosi, è possibile dare un'occhiata alle registrazioni di ciascun valore singolo.

Da qui ho appreso che con il rubinetto aperto l'acqua scorre a una velocità di circa 1 litro ogni 5 secondi.
Ma in questo modo, probabilmente, non è molto comodo guardare la statistica. In majordomo ci sono anche altre opzioni per vedere i grafici di consumo per giorni, settimane e mesi. Ecco, ad esempio, un grafico di consumo a barre.

Al momento ho dati solo per una settimana. Tra un mese questo grafico sarà più indicativo: ogni giorno avrà una propria barra. Un po' rovinano il quadro le correzioni dei valori che inserisco manualmente (la barra più alta). E non è ancora chiaro se ho impostatole prime cifre quasi con un cubo in meno, oppure se sia un bug nel firmware e non tutti i litri siano stati conteggiati. Serve più tempo.
Sui grafici stessi bisogna ancora lavorare, imbiancare, pitturare. Forse costruirò anche un grafico di consumo di memoria a scopi di debug — magari lì c'è una fuga. Potrei anche in qualche modo visualizzare i periodi in cui internet era assente. Per ora tutto questo è ancora a livello di idea.
Conclusione
Oggi il mio appartamento è diventato un po' più intelligente. Con questo piccolo dispositivo sarà più facile monitorare il consumo d'acqua in casa. Se prima mi lamentavo "ancora una volta abbiamo consumato troppa acqua questo mese", ora potrò trovare la fonte di questo consumo.
A qualcuno potrebbe sembrare strano controllare le letture sullo schermo, se è a un metro dal contatore stesso. Ma in un futuro non molto distante prevedo di trasferirmi in un altro appartamento, dove ci saranno diversi colonne d'acqua, e i contatori probabilmente saranno posizionati sul pianerottolo. Quindi, un dispositivo di rilevamento remoto delle letture sarà molto utile.
Intendo anche ampliare le funzionalità del dispositivo. Sto già dando un'occhiata alle valvole motorizzate. Ora, per passare dall'acqua del boiler a quella comunale, devo girare 3 rubinetti in una nicchia difficilmente accessibile. Sarebbe molto più comodo farlo con un solo pulsante con la relativa indicazione. E naturalmente, è opportuno implementare la protezione dalle perdite.
Nell'articolo ho descritto la mia variante di un dispositivo basato su ESP8266. A mio avviso, ho realizzato una versione molto interessante del firmware in micropython utilizzando coroutine: semplice e graziosa. Ho cercato di descrivere numerosi dettagli e errori che ho incontrato lungo il percorso. Forse ho descritto tutto in modo troppo dettagliato, personalmente io come lettore preferisco scorrere ciò che è in più piuttosto che dover immaginare ciò che è stato omesso.
Come sempre, sono aperto a critiche costruttive.
Fonte: habr.com
