Household Power Use Pt. 2 – Move to ESP8266

In a previous post I wrote about displaying household power use on a small OLED display. Here, I’ll describe further steps towards making a standalone device by reading the data and controlling the display with a wifi-enabled microcontroller.

This turned out to be a substantial project. The limited power and storage of the ESP-12E required writing a micropython library to access the energy gateway and creating a memory-light copy of pretty fonts for the display.

Finally, I wanted to make the display look a little nicer, so I also added a PWM-controlled panel meter to illustrate current power use.

Preliminaries

To start, I’ll set up a nice working environment to work in.

python3 -m venv venv
source venv/bin/activate

and install a few tools for convenience

pip3 install mpfshell
pip3 install mpy-cross

 

Getting the Data

Like in the previous post, I’ll be pulling data from my smart meter. The module I used last time, evilpete’s RainEagle module, was too difficult to port over to MicroPython, so instead I ended up writing a lightweight library, uEagle, which is detailed in this post.

 

Small Fonts

To move to a microcontroller it is necessary to minimize the memory used by the typeface. To that end I’ll use peterhinch’s font-to-py.

pip3 install freetype-py
git submodule add https://github.com/peterhinch/micropython-font-to-py tools/micropython-font-to-py

This tool provides two things. First, font_to_py.py renders a subset of a ttf font to raw bytes in a python file, allowing fast but memory-light access. Second, it provides a Writer class that uses fonts in this format to write to a display like the SSD1306.

Preparing a nice looking font for the display simply involves fetching the font and running font_to_py on it. I’m again using Roboto Regular, but any ttf should work. For flexibility I make both half-height (15px) and full-height (32px) variants.

wget https://github.com/google/fonts/raw/master/apache/robotomono/RobotoMono-Regular.ttf
python3 tools/micropython-font-to-py/font_to_py.py -x -f RobotoMono-Regular.ttf 15 font_half.py
python3 tools/micropython-font-to-py/font_to_py.py -x RobotoMono-Regular.ttf 32 font_full.py

All that’s left is to get the writer.py and the fonts onto the board. As peterhinch notes, writer.py is quite large and requires freezing/compilation to run on the ESP8266.

python3 -m mpy_cross font_full.py
mpfshell -n -o ttyUSB0 -c put font_full.mpy

python3 -m mpy_cross font_half.py
mpfshell -n -o ttyUSB0 -c put font_half.mpy

cd tools/micropython-font-to-py/writer
python3 -m mpy_cross writer.py
mpfshell -n -o ttyUSB0 -c put writer.mpy

Writing to the Display

With nice fonts on the microcontroller, the next step is to get a them displayed on the SSD1306.

import machine
import ssd1306
import writer
import font_half

#Setting up the OLED display
scl_pin = machine.Pin(14) #"D5" on the NodeMCU
sda_pin = machine.Pin(12) #"D6" on the NodeMCU
i2c = machine.I2C(scl=scl_pin, sda=sda_pin)

#Displays are either 128x32 or 128x64
ssd = ssd1306.SSD1306_I2C(128, 32, i2c)

wri = writer.Writer(ssd, font_half)

#Writing a test message
wri.set_textpos(ssd, 0,0)
wri.printstring('Hello\nGoodbye')
ssd.show()

Micropython’s SSD1306 drivers have quite a few drawing routines built in, so unlike in the last post, there’s no need to create a PIL image/artist. (Unfortunately there’s still no support for smooth scrolling.)

Adding an Analog Meter

In addition to the digital display from my previous post, I’m going to add an analog display – a 200μA panel meter I found at an antique shop. To do so I’m going to use one of the ESP8266’s pulse-width modulation (PWM) pins and an ≈16kΩ resistor to map the board’s 3.3V full-cycle output down to the gauge’s 200mA maximum. (The ammeter’s response is sufficiently slow that there’s no need to low-pass the PWM signal.)

The relevant code to monitor a signal f(x), with range (-1,1), might be

import machine

pwm = machine.PWM(machine.Pin(4), freq=1000)

from math import sin
from utime import sleep_ms
for t in range(0,100):
    theta = t * 2 * math.pi / 100
    sig_value = sin(theta)
    pwm.duty(int(1023 * (sig_value + 1)/2))
    utime.sleep_ms(50)

Putting it all together

To build the final product I wired up the ESP-12E, panel meter and display as seen here:

The code depends on a few external resources which need to be uploaded to the board

  • Network credentials, stored in network_creds.json
  • EAGLE credentials, stored in eagle_creds.json
  • font_half.mpy and writer.mpy, as described above
  • slimDNS.mpy, nickovs’ mDNS server (optional – one can also specify the EAGLE’s IP address explicitly)

With these compiled and uploaded to the board, all that remains is boot.py and main.py.

boot.py

boot.py does two things – it connects to the wireless network and synchronizes the board’s clock

import gc
import utime
import network

#Get on wifi using network_creds.json
try:
    import ujson
    creds = ujson.load(open('network_creds.json','r'))
    sta_if = network.WLAN(network.STA_IF)
    if not (sta_if.isconnected() and sta_if.config('essid') == creds['essid']):
        sta_if.active(True)
        sta_if.connect(creds['essid'], creds['password'])
        for i in range(10):
            if sta_if.isconnected():
                break
            else:
                utime.sleep(1)

    assert(sta_if.isconnected())
except:
    print('Couldn\'t connect to network')

del(creds)
del(sta_if)
del(network)
del(ujson)


#Set network time
import ntptime
for _ in range(4):
    try:
        ntptime.settime()
        break
    except:
        utime.sleep(2)
del(ntptime)

gc.collect()

main.py

The main script sets up the display, PWM pin, and connects to the EAGLE before entering a while True loop that repeatedly polls the EAGLE for new data and updates the displays. It also keeps a running maximum demand value and adjusts the panel meter’s scale.

#Set up the power meter
import machine
import ssd1306
import writer
import font_half #font_full
from uEagle import Eagle
from micropython import const

from utime import ticks_ms, ticks_diff, sleep, sleep_ms

SCL_PIN = const(14)
SDA_PIN = const(12)
PWM_PIN = const(4)
SSD_WIDTH = const(128)
SSD_HEIGHT = const(32)

DATA_REFRESH_TIME = 5.0
DISPLAY_REFRESH_TIME = 1.0

for _ in range(4):
    try:
        import ntptime
        ntptime.settime()
        del(ntptime)
        break
    except:
        sleep(2)

#Setting up the OLED display
i2c = machine.I2C(scl=machine.Pin(SCL_PIN), sda=machine.Pin(SDA_PIN))
ssd = ssd1306.SSD1306_I2C(SSD_WIDTH, SSD_HEIGHT, i2c)
wri = writer.Writer(ssd, font_half)

#Setting up analog PWM meter
pwm = machine.PWM(machine.Pin(PWM_PIN))
pwm.freq(1000)
pwm.duty(0)

#Setting up the EAGLE
import ujson
try:
    eagle_creds = ujson.load(open('eagle_creds.json','r'))
    CLOUD_ID = eagle_creds['cloud_id']
    INSTALL_CODE = eagle_creds['install_code']
    ADDRESS = eagle_creds.get('address', None)
    del(eagle_creds)
except:
    raise Exception('Couldn\'t read credentials from eagle_creds.json.')

if ADDRESS is None:
    import network
    from slimDNS import SlimDNSServer
    sta_if = network.WLAN(network.STA_IF)
    ap_if = network.WLAN(network.AP_IF)
    my_addr = sta_if.ifconfig()[0]
    ap_was_active = ap_if.active()
    ap_if.active(False)
    server = SlimDNSServer(my_addr)
    ADDRESS = server.resolve_mdns_address('eagle-{}.local'.format(CLOUD_ID))
    ADDRESS = '{}.{}.{}.{}'.format(*ADDRESS)
    ap_if.active(ap_was_active)
    server.sock.close()

    del(ap_was_active)
    del(sta_if)
    del(ap_if)
    del(network)
    del(SlimDNSServer)
    del(server)

gc.collect()

eagle = Eagle(CLOUD_ID, INSTALL_CODE, address=ADDRESS)

def get_demand():
    demand_data = eagle.get_instantaneous_demand()
    demand = demand_data['Demand']
    timestamp = demand_data['TimeStamp']
    return timestamp, demand

def write_to_ssd(s):
    _ = wri.set_textpos(ssd, 0,0)
    wri.printstring('{}'.format(s))
    ssd.show()

#max_demand = float(eagle.get_demand_peaks()['PeakDelivered'])
max_demand = 0.

t_data = 0
t_disp = 0
while True:
    t0 = ticks_ms()

    if ticks_diff(t0, t_data) > 1000 * DATA_REFRESH_TIME:
        t_data = t0
        try:
            timestamp, demand = get_demand()
        except OSError:
            pass #Probably trouble connecting to device
        max_demand = max(demand, max_demand)

    if ticks_diff(t0, t_disp) > 1000 * DISPLAY_REFRESH_TIME:
        t_disp = t0
        write_to_ssd('{} kW\nMax: {} kW'.format(demand, max_demand))
        pwm.duty(int(demand * 1000 / max_demand))

    gc.collect()

    t1 = ticks_ms()
    wait_data = int(1000 * DATA_REFRESH_TIME - ticks_diff(t1, t_data))
    wait_disp = int(1000 * DISPLAY_REFRESH_TIME - ticks_diff(t1, t_disp))
    sleep_ms(min(wait_data, wait_disp))

And here it is in action:

Future Work

The code above works, but is quite unstable. It doesn’t handle errors from uEagle at all, and since most of the code lives in main.py, a single crash ends the main loop.

In the final part of this project I’ll focus on making the display stable enough to serve as a practical household display by using MicroPython’s uasyncio library.

I’ll also try to incorporate some of the features from part 1, including a clock and scrolling line graph, and add the ability to switch between display modes with a button.

 

Joseph Albert

Leave a Reply

Your email address will not be published. Required fields are marked *