Un tempo, i sistemi di automazione domestica, comunemente noti come 'smart home', erano estremamente costosi e accessibili solo ai benestanti. Oggi, sul mercato, si possono trovare kit piuttosto economici con sensori, pulsanti/interruttori e dispositivi esecutivi per gestire illuminazione, prese, ventilazione, approvvigionamento idrico e altri consumatori. Anche il più poco esperto dei fai-da-te può avvicinarsi al meraviglioso mondo dell'automazione, assemblando facilmente dispositivi per una casa intelligente a un prezzo contenuto.

Di solito, i dispositivi proposti sono o sensori o meccanismi esecutivi. Questi consentono di realizzare facilmente scenari come 'accendere la luce al rilevamento di movimento' o 'l'interruttore vicino all'uscita spegne tutte le luci dell'appartamento'. Tuttavia, la telemetria ha avuto qualche difficoltà. Al massimo, si ottiene un grafico della temperatura e dell'umidità, o la potenza istantanea in una presa specifica.
Di recente ho installato dei contatori dell'acqua con uscita a impulsi. Ad ogni litro che passa attraverso il contatore, si attiva un reed switch che chiude il contatto. Ora rimane solo da fare una cosa: collegarsi ai fili e cercare di trarne un vantaggio. Ad esempio, analizzare il consumo d'acqua per ore e giorni della settimana. E se ci sono più colonne d'acqua nell'appartamento, è più comodo vedere tutti i dati attuali su un unico schermo piuttosto che cercare in nicchie difficili da raggiungere con una torcia.
Sotto trovate la mia soluzione basata su ESP8266, che conteggia gli impulsi dai contatori dell'acqua e invia le letture al server della casa intelligente tramite MQTT. Programmeremo in micropython utilizzando la libreria uasyncio. Mentre creavo il firmware, ho incontrato alcune difficoltà interessanti di cui vi parlerò in questo articolo. Iniziamo!
Schema

Il cuore dell'intero schema è il modulo basato sul microcontrollore ESP8266. Inizialmente era previsto l'ESP-12, ma il mio era difettoso. Ho dovuto accontentarmi del modulo ESP-07 che era disponibile. Per fortuna, 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 funziona bene.
Il cablaggio del modulo è standard:
- il pulsante di reset con pull-up e un condensatore (anche se entrambe le cose sono già presenti all'interno del modulo)
- Il segnale enable (CH_PD) è collegato all'alimentazione
- GPIO15 è collegato a massa. Questo è necessario solo all'avvio, ma non ho nulla da collegare a questo pin.
Per mettere il modulo in modalità di programmazione è necessario collegare GPIO2 a massa, e per maggiore comodità 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, durante l'alimentazione o subito dopo il reset. In questo modo, il modulo si avvia normalmente oppure passa alla modalità di flashing. Dopo l'avvio, questo pin può essere utilizzato come un normale GPIO. E dato che è già presente un pulsante, è possibile assegnargli una funzione utile.
Per la programmazione e il debug utilizzerò la UART, che ho collegato al connettore. Quando necessario, collego semplicemente un adattatore USB-UART. È importante non dimenticare che il modulo è alimentato a 3.3V. Se si dimentica di commutare l'adattatore a questa tensione e si forniscono 5V, il modulo molto probabilmente si brucerà.
Non ho problemi con l'elettricità in bagno: la presa è situata a circa un metro dai contatori, quindi alimenterò il tutto a 220V. Come fonte di alimentazione utilizzerò un piccolo di Tenstar Robot. Personalmente, non ho molta esperienza con l'elettronica analogica e di potenza, quindi trovo utile un alimentatore pronto in un corpo compatto.
Per segnalare le modalità di funzionamento, ho previsto un LED collegato a GPIO2. Tuttavia, non l'ho saldato, poiché il modulo ESP-07 ha già un LED, anch'esso collegato a GPIO2. Ma sulla scheda ci sarà — nel caso volessi portare questo LED all'esterno.
Passiamo alla parte più interessante. I contatori d'acqua non hanno alcuna logica; non si possono chiedere le letture attuali. L'unica cosa a cui abbiamo accesso sono gli impulsi: il contatto del relè si chiude ogni litro. I terminali del relè sono collegati a GPIO12/GPIO13. Il resistore pull-up lo attiverò tramite il software all'interno del modulo.
Inizialmente avevo dimenticato di prevedere i resistori R8 e R9 e nella mia versione della scheda non ci sono. Ma poiché sto già pubblicando lo schema per la visione pubblica, vale la pena correggere questa svista. I resistori sono necessari per non bruciare la porta nel caso in cui il firmware abbia un malfunzionamento e imposti un'unità sul pin, mentre il relè cortocircuita questa linea a terra (con il resistore passeranno al massimo 3.3V/1000Ω = 3.3mA).
È tempo di pensare a cosa fare in caso di interruzione dell'elettricità. Una prima opzione sarebbe richiedere al server i valori iniziali dei contatori all'avvio. Tuttavia, ciò comporterebbe una complessità significativa nel protocollo di comunicazione. Inoltre, il funzionamento del dispositivo dipenderebbe dallo stato del server. Se, dopo l'interruzione della corrente, il server non si avviasse (o si avviasse in seguito), il contatore dell'acqua non potrebbe richiedere i valori iniziali e funzionerebbe in modo errato.
Perciò ho deciso di implementare il salvataggio dei valori dei contatori in un circuito di memoria, collegato tramite I2C. Non ho particolari requisiti per le dimensioni della memoria flash: è necessario memorizzare solo 2 numeri (il numero di litri nei contatori dell'acqua calda e fredda). Anche il modulo più piccolo va bene. Bisogna però prestare attenzione al numero di cicli di scrittura. La maggior parte dei moduli offre 100.000 cicli, alcuni arrivano fino a un milione.
A prima vista, un milione sembra molto. Ma in quattro anni di permanenza nel mio appartamento, ho consumato poco più di 500 metri cubi d'acqua, ovvero 500.000 litri! E 500.000 registrazioni su una flash. E questo solo per l'acqua fredda. Certo, si potrebbe saldare il chip ogni paio d'anni, ma ho scoperto che esistono dei chip FRAM. Dal punto di vista della programmazione, è lo stesso I2C EEPROM, solo con un numero incredibilmente elevato di cicli di riscrittura (centinaia di milioni). Solo che finora non sono riuscito a fare un salto al negozio per procurarmeli, quindi per ora restiamo con il solito 24LC512.
Scheda madre
Inizialmente avevo pianificato di realizzare la scheda in casa. Così la scheda è stata progettata come monofaccia. Ma dopo aver combattuto per un'ora con un ferro da stiro laser e una maschera da saldatura (senza di essa, in un certo senso, non è 'comme il faut'), ho deciso di ordinare le schede dai cinesi.

Quasi nel momento di ordinare la scheda, mi sono reso conto che oltre al chip della memoria flash, potevo collegare qualcosa di utile al bus I2C, come ad esempio un display. Cosa esattamente visualizzare è ancora un interrogativo, ma è necessario portare le connessioni sulla scheda. E visto che ho intenzione di ordinare le schede dalla fabbrica, limitarmi a una scheda unilaterale non aveva più senso, quindi le linee sul I2C sono le uniche sul retro della scheda.
Ho avuto anche un grande problema legato alla progettazione unilaterale. Poiché la scheda era disegnata come unilaterale, le piste e i componenti SMD dovevano essere posizionati da un lato, mentre i componenti di uscita, i connettori e l'alimentatore dall'altro. Quando dopo un mese ho ricevuto le schede, ho dimenticato il piano iniziale e ho saldato tutti i componenti sul lato anteriore. Solo quando è stato il turno di saldare l'alimentatore ho scoperto che polo positivo e negativo erano invertiti. Ho dovuto ripiegare con dei ponticelli. Nella foto sopra ho già modificato il layout, ma la massa viene trasferita da una parte della scheda all'altra attraverso i pin del pulsante Boot (anche se sarebbe stato possibile condurre una pista sul secondo strato).
Ecco com'è venuto

Case
Il passo successivo è il corpo. Con una stampante 3D questo non è un problema. Non mi sono troppo impegnato: ho semplicemente disegnato una scatola della dimensione necessaria e fatto i tagli nei punti giusti. Il coperchio è fissato al corpo con delle piccole viti.

Ho già accennato che il pulsante Boot può essere usato come pulsante multifunzione — quindi lo porteremo sul pannello frontale. A questo scopo, ho disegnato un apposito “pozzetto” dove risiede il pulsante.

All'interno del corpo ci sono anche degli innesti su cui si fissa la scheda con una sola vite M3 (sulla scheda non c'era più spazio).
Ho scelto il display quando ho stampato il primo prototipo del corpo. Un comune display a due righe non si adattava a questo corpo, ma ho trovato un display OLED SSD1306 128×32. È un po' piccolo, ma non lo guardo tutti i giorni — va bene.
Pensando a come e dove saranno passati i cavi, ho deciso di attaccare il display in mezzo al corpo. L'ergonomia, ovviamente, è pessima: il pulsante è in alto, il display in basso. Ma ho già detto che l'idea di montare il display è arrivata troppo tardi e non avevo voglia di riprogettare la scheda per spostare il pulsante.
Dispositivo assemblato. Il modulo display è incollato con colla termica.


Il risultato finale è visibile sul KDPV.
Firmware
Passiamo alla parte software. Per questi piccoli progetti mi piace molto utilizzare il linguaggio Python ()- il codice risulta molto compatto e chiaro. Per fortuna qui non è necessario scendere a livello di registri per ottimizzare i microsecondi — tutto si può fare direttamente da Python.
Sembra tutto semplice, ma non lo è — il dispositivo ha diverse funzioni indipendenti:
- L'utente preme il pulsante e guarda il display.
- I litri scorrono e aggiornano i valori nella memoria flash.
- Il modulo monitora il segnale WiFi e si ricollega se necessario.
- E senza una lampadina lampeggiante, non si può proprio fare.
Non possiamo permettere che una funzione non funzioni mentre un'altra comunque sta rallentando. Ho già assaporato amaro in altri progetti e ora vedo lo stesso tipo di problemi, come "un litro in più è stato perso perché in quel momento il display si stava aggiornando" o "l'utente non può fare nulla finché il modulo si connette al WiFi". Certo, alcune cose possono essere fatte attraverso le interruzioni, ma si potrebbe incorrere in limitazioni di durata, di annidamento delle chiamate o di modifica non atomica delle variabili. Inoltre, il codice che gestisce tutto e subito si trasforma rapidamente in un guazzabuglio.
In ho utilizzato la classica multitasking a switchover e FreeRTOS, ma in questo caso il modello . Tra l'altro, l'implementazione in Python delle coroutine è semplicemente fantastica: per il programmatore è tutto fatto in modo semplice e comodo. Basta scrivere la logica, basta solo dire dove si può passare tra i thread.
Propongo di studiare le differenze tra il multitasking a switchover e il multitasking concorrente come materia opzionale. Ma 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) — questo implementa il ripristino dopo un'interruzione dell'alimentazione.
Il pin è inizializzato con un pull-up interno: se il reed switch è chiuso, sulla linea c'è zero, se è aperto la linea viene tirata all'alimentazione e il controller legge uno.
Qui viene avviato anche un task separato che eseguirà il polling del pin. Ogni contatore avvierà il proprio task. Ecco il suo codice.
""" Esegui il polling del pin e aumenta il valore quando è passato un altro litro """
async def _switchcheck(self):
last_checked_pin_state = self._pin.value() # Ottieni stato iniziale
# Polling per un cambio di stato del pin
while True:
state = self._pin.value()
if state != last_checked_pin_state:
# Lo stato è cambiato: agisci ora.
last_checked_pin_state = state
if state == 0:
self._another_litre_passed()
# Ignora ulteriori cambiamenti di stato finché l'interruttore non si stabilizza
await asyncio.sleep_ms(Counter.debounce_ms)Un ritardo di 25 ms è necessario per filtrare il rimbalzo dei contatti, e allo stesso tempo regola quanto spesso si risveglia il task (mentre questo task è in sonno, gli altri task possono lavorare). Ogni 25 ms, la funzione si risveglia, controlla il pin e se i contatti del microswitch si sono chiusi, significa che un altro litro è passato attraverso il contatore e questo deve essere elaborato.
def _another_litre_passed(self):
self._value += 1
self._value_changed = True
self._value_storage.write(self._value)Elaborare il litro successivo è banale: si incrementa semplicemente il contatore. E sarebbe utile registrare il nuovo valore su flash.
Per comodità d'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 sfruttiamo le bellezze di Python e della libreria uasync per rendere l'oggetto contatore waitable (come si traduce in italiano? Quello di cui si può aspettare?)
def __await__(self):
while not self._value_changed:
yield from asyncio.sleep(0)
return self.value()
__iter__ = __await__ Questa è una funzione molto comoda che attende che il valore del contatore venga aggiornato—la funzione si risveglia di tanto in tanto e controlla il flag _value_changed. Il bello di questa funzione è che il codice chiamante può andare in attesa sulle chiamate a questa funzione e rimanere in attesa fino a ricevere un nuovo valore.
E per le interruzioni?Sì, qui potete prendere in giro, dicendo che ho parlato delle interruzioni ma poi ho organizzato un semplice polling del pin. In realtà, le interruzioni sono state la prima cosa che ho provato. Con l'ESP8266 si può configurare un'interruzione sul front, e anche scrivere un gestore di questa interruzione in Python. In questa interruzione si può aggiornare il valore della variabile. Probabilmente, questo basterebbe se il contatore fosse un dispositivo slave—uno che attende che venga richiesto il suo valore.
Sfortunatamente (o per fortuna?) il mio dispositivo è attivo, deve inviare autonomamente i messaggi tramite il protocollo MQTT e memorizzare i dati nella EEPROM. Qui entrano in gioco i vincoli: nelle interruzioni non è possibile allocare memoria e utilizzare uno stack grande, quindi possiamo dimenticarci di inviare messaggi sulla rete. Ci sono funzionalità come micropython.schedule(), che consentono di avviare una funzione appena possibile, ma sorge la domanda: “e a che serve?”. Potremmo inviare immediatamente un messaggio, ma un'interruzione potrebbe intervenire e rovinare i valori delle variabili. Oppure, ad esempio, un nuovo valore del contatore potrebbe arrivare dal server mentre stiamo ancora scrivendo il vecchio. In sintesi, è necessario creare una sincronizzazione o trovare un modo alternativo per gestire la situazione.
Inoltre, di tanto in tanto si verifica un RuntimeError: schedule stack full e chissà il perché?
Con il polling esplicito e uasync, in questo caso risulta più elegante 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) # Evitare la creazione/distruzione del buffer ad 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)Lavorare direttamente con i byte in Python è complicato, eppure sono proprio i byte a essere scritti in memoria. È stato necessario implementare una conversione tra numeri interi e byte utilizzando la libreria ustruct.
Per evitare di dover passare ogni volta l'oggetto I2C e l'indirizzo della cella di memoria, ho incapsulato 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 i seguenti 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 vero e proprio — si è trovata La utilizzeremo.
Tutto ciò che è interessante è 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 delle lampadine e dei pulsanti, così come gli oggetti contatori dell'acqua fredda e calda.
L'inizializzazione non è così banale.
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 configurare le impostazioni della libreria mqtt_as, viene utilizzato un ampio dizionario di diverse configurazioni — config. La maggior parte delle impostazioni predefinite è adatta, ma molte devono essere definite esplicitamente. Per evitare di inserire le impostazioni direttamente nel codice, le conservo in un file di testo chiamato config.txt. Questo consente di modificare il codice indipendentemente dalle impostazioni e di replicare diversi dispositivi con parametri differenti.
L'ultimo blocco di codice avvia diverse coroutine per gestire le varie funzioni del sistema. Ecco ad esempio una coroutine che gestisce i 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 dal contatore e, non appena viene ricevuto, 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 di base MQTTClient si gestisce autonomamente, avviando da sola la connessione WiFi e riconnettendosi quando la connessione viene a mancare. In caso di modifiche 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('WiFi è attivo.')
duration = ticks_diff(ticks_ms(), self.internet_outage_start) // 1000
await self.publish_debug_msg('RicollegatoDopo', duration)
else:
self.internet_outages += 1
self.internet_outage_start = ticks_ms()
self.dprint('WiFi non disponibile.')
await asyncio.sleep(0)La funzione è stata ripresa onestamente dagli esempi. In questo caso, conta il numero di disconnessioni (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 chiamata tramite await, mentre possono essere chiamate solo funzioni nel cui corpo è presente un altro await.
Oltre alla connessione WiFi, è necessario stabilire una connessione con il broker MQTT (server). Anche questo è gestito dalla libreria, mentre a noi resta la possibilità di fare qualcosa di utile quando 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 può inviare i valori attuali dei contatori tramite il messaggio corrispondente.
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 in arrivo e, a seconda del tema (nome del messaggio), aggiorna il valore 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 quando la connessione è stabilita. Se non c'è connessione, il messaggio viene ignorato.
E questa è solo una funzione comoda che formatta 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))
Così tanto testo e non abbiamo ancora lampeggiato il LED. Ecco.
# 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 viene meno (o è in fase di stabilizzazione), il dispositivo lampeggerà rapidamente. Una volta stabilita la connessione, il dispositivo lampeggerà ogni 5 secondi. Se necessario, possono essere implementate altre modalità di lampeggiamento.
Ma il LED è solo un gioco. Abbiamo anche messo gli occhi sul 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)Ecco di cosa parlavo: è così semplice e comodo con le coroutine. Questa piccola funzione gestisce TUTTA l'interazione con l'utente. La coroutine aspetta semplicemente la pressione di un pulsante e accende il display per 3 secondi. Sul display vengono visualizzate le attuali letture dei contatori.
Ci sono ancora un paio di dettagli. Ecco la funzione che gestisce (ri)lancio di tutto. Il ciclo principale si occupa solo di inviare diverse informazioni di debug ogni minuto. Insomma, vi presento così come sono — non serve commentare molto.
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("Tempo di attività", mins)
await self.publish_debug_msg("Ripubblicazioni", self.REPUB_COUNT)
await self.publish_debug_msg("Interruzioni", self.internet_outages)
await self.publish_debug_msg("MemoriaLibera", mem_free)
await self.publish_debug_msg("MemoriaAllocata", mem_alloc)
except Exception as e:
self.dprint("Si è verificata un'eccezione: ", e)
mins += 1
await asyncio.sleep(60)Ecco un'altra paio di impostazioni 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())C'è qualcosa che non va nella mia memoria
Quindi, tutto il codice è pronto. Ho caricato i file utilizzando l'utilità ampy, che consente di trasferirli sulla memoria interna (quella nel ESP-07) e di accedervi come a normali file. Ho anche caricato lì le librerie che utilizzo: mqtt_as, uasyncio, ssd1306 e collections (usata all'interno di mqtt_as).
Avviamo e… otteniamo MemoryError. Più cercavo di capire dove stesse perdendo memoria, più questa errore si presentava precocemente. Una rapida ricerca su Google mi ha portato a capire che il microcontrollore ha solo 30 kB di memoria, mentre 65 kB di codice (compresi i pacchetti) non ci stanno affatto.
Ma c'è una soluzione. Si scopre che 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 anche il compilatore ha bisogno di una certa quantità di RAM per funzionare.
Il trucco consiste nel liberare il microcontrollore da una compilazione pesante in termini di risorse. È possibile compilare i file su un computer potente e caricare il bytecode già pronto nel microcontrollore. Per questo è necessario scaricare il firmware micropython e compilarlo. .
Non ho scritto un Makefile, ma ho compilato manualmente tutti i file necessari (compresi i librerie) più o meno così
mpy-cross water_counter.pyRimane solo da caricare i file con estensione .mpy, ricordando di eliminare preventivamente i corrispondenti .py dal file system del dispositivo.
Ho svolto tutto lo sviluppo nell'IDE ESPlorer. Questo programma consente di caricare script nel microcontrollore ed eseguirli immediatamente. Nel mio caso, tutta la logica e la creazione di tutti gli oggetti si trovano nel file water_counter.py (.mpy). Tuttavia, affinché tutto questo venga eseguito automaticamente all'avvio, deve esserci anche un file di nome main.py. Inoltre, deve essere proprio un .py, e non un .mpy precompilato. Ecco il suo contenuto triviale
import water_counterIniziamo — tutto funziona. Ma la memoria libera è pericolosamente bassa — circa 1 kB. Ho anche piani per espandere le funzionalità del dispositivo, e questo kilobyte sarà chiaramente insufficiente. Ma sembra che ci sia una soluzione anche per questo caso.
Il fatto è questo. Anche se i file sono compilati in bytecode e si trovano nel file system interno, in realtà vengono comunque caricati nella memoria RAM ed eseguiti da lì. Ma sembra che micropython possa eseguire bytecode direttamente dalla memoria flash, ma per questo è necessario incorporarlo direttamente nel firmware. Non è difficile, anche se sul mio netbook ci sono voluti un bel po' di tempo (lì ho trovato Linux).
L'algoritmo è il seguente:
- Scarica e installa . Questo strumento compila il compilatore e le librerie per programmi su ESP8266. Viene compilato seguendo le istruzioni nella pagina principale del progetto (ho scelto l'installazione STANDALONE=yes).
- Scarica
- Inserisci le librerie necessarie in ports/esp8266/modules all'interno della struttura di micropython.
- Compiliamo il firmware secondo le istruzioni nel file
- Carichiamo il firmware nel microcontrollore (lo faccio su Windows utilizzando i programmi ESP8266Flasher o esptool in Python).
Ora, ‘import ssd1306’ caricherà il codice direttamente dal firmware, e non verrà utilizzata memoria RAM per questo. In questo modo 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.5kB di RAM libera. Questo consentirà di implementare ancora un bel po' di funzionalità utili in futuro. E se la memoria diventa insufficiente, si può anche inserire il programma principale nel firmware.
E adesso cosa fare con questo?
Ok, l'hardware è assemblato, il firmware è scritto, la scatola è stampata, il dispositivo è attaccato al muro e lampeggia felice. Ma per ora è ancora una scatola nera (sia in senso letterale che figurato) e il suo utilizzo è ancora piuttosto limitato. È tempo di fare qualcosa con i messaggi MQTT che vengono inviati al server.
La mia “smart home” gira su . Il modulo MQTT è disponibile di default o può essere facilmente installato dal marketplace delle estensioni— non ricordo più da dove sia venuto. MQTT non è autonomo: ha bisogno di un broker, un server che riceve, ordina e inoltra i messaggi MQTT ai client. Io utilizzo mosquitto, che (come majordomo) gira tutto sullo stesso netbook.
Dopo che un dispositivo invia un messaggio anche solo una volta, il valore apparirà immediatamente nell'elenco.

Questi valori possono ora essere associati agli oggetti del sistema, utilizzati negli scenari di automazione e sottoposti a diverse analisi — tutto ciò esula dallo scopo di questo articolo. Per chi è interessato al sistema majordomo, posso raccomandare — un amico che sta anche costruendo una casa intelligente e spiega in modo chiaro come configurare il sistema.
Mostrerò solo un paio di grafici. Questo è un semplice grafico dei valori su base giornaliera.

Si nota che durante la notte quasi nessuno ha utilizzato l'acqua. Qualche volta qualcuno è andato in bagno, e sembra che il filtro ad osmosi inversa assorba un paio di litri durante la notte. Al mattino, il consumo aumenta notevolmente. Di solito utilizzo l'acqua del boiler, ma qui ho voluto fare un bagno e ho temporaneamente cambiato su acqua calda di rete — questo è anche bene visibile nel grafico sottostante.
Da questo grafico ho appreso che andare in bagno richiede 6-7 litri d'acqua, fare una doccia — 20-30 litri, lavare i piatti circa 20 litri, e per fare un bagno ci vogliono 160 litri. In un giorno, la mia famiglia consuma circa 500-600 litri.
Per i più curiosi, è possibile dare un'occhiata alle registrazioni di ciascun valore specifico.

Da qui ho scoperto 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 visualizzare le statistiche. In majordomo ci sono anche possibilità di vedere 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ù rappresentativo: ogni giorno avrà una barra separata. Le correzioni manuali che inserisco (la barra più alta) distorcono un po' la situazione. Non è chiaro se ho impostato male i primi valori, quasi un metro in meno, o se si tratta di un bug nel firmware e non tutti i litri sono stati contabilizzati. Ho bisogno di più tempo.
Dovrò ancora lavorare sui grafici, ripulirli e colorarli. Potrei anche costruire un grafico del consumo di memoria per scopi di debug — chissà, potrebbe esserci qualche perdita. Forse mostrerò anche i periodi in cui l'internet è stato assente. Per ora tutto questo è solo un'idea.
Conclusione
Oggi il mio appartamento è diventato un po' più intelligente. Con questo piccolo dispositivo mi sarà più facile monitorare il consumo d'acqua in casa. Se prima mi lamentavo 'ancora troppa acqua consumata questo mese', adesso sarò in grado di individuare la fonte di questo consumo.
A qualcuno potrebbe sembrare strano guardare le letture sullo schermo se si trova a un metro dal contatore stesso. Ma in un futuro non molto lontano, ho intenzione di trasferirmi in un altro appartamento, dove ci saranno diversi montanti dell'acqua, e i contatori saranno probabilmente posizionati nel pianerottolo. Quindi, un dispositivo per la lettura remota delle misurazioni sarà molto utile.
Intendo anche ampliare la funzionalità del dispositivo. Sto già valutando delle valvole motorizzate. Attualmente, per passare dall'acqua della caldaia a quella comunale devo girare tre rubinetti in una nicchia difficile da raggiungere. Sarebbe molto più comodo farlo con un solo pulsante e una corretta indicazione. E, ovviamente, vale la pena implementare la protezione dalle perdite.
Nell'articolo ho descritto la mia versione del dispositivo basato su ESP8266. A mio parere, ho realizzato una soluzione piuttosto interessante per il firmware su micropython utilizzando coroutine — semplice e graziosa. Ho cercato di descrivere molte sfumature e problemi incontrati lungo il cammino. Forse ho dettagliato troppo, ma personalmente trovo più semplice scorrere oltre l'eccesso piuttosto che poi cercare di capire ciò che non è stato detto.
Come sempre sono aperto a critiche costruttive.
Fonte: habr.com
