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.mpyandwriter.mpy, as described aboveslimDNS.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.