MicroPython on ESP Using Jupyter Notebook | by Marcelo Rovai | Towards Data Science

Let’s play with MicroPython on an ESP using a Jupyter Notebook in order to get data from sensors and taking action in a physical world.
On a previous article, we explored how to control a Raspberry Pi using Jupyter Notebook: Physical Computing Using Jupyter Notebook
It was a great experience, and once the project worked very well I thought, “how about to also test Jupyter Notebook on an ESP8266 (or even on ESP32) using MicroPython?”.
As we all know, the Jupyter Notebook is an open-source web application that allows you to create and share documents that contain live code, equations, visualizations and narrative text. Uses include data cleaning and transformation, numerical simulation, statistical modeling, data visualization, machine learning, and much more. For “much more”, we have also explored “Physical Computing”.
So far on my projects, I have mostly explored IoT and physical computing projects using ESP8266–01, 8266–12E (NodeMCU) and ESP32 programmed by an Arduino IDE, using its C/C++ type language. But another great tool to be used on programming those devices is MicroPython:
MicroPython is a lean and efficient implementation of the Python 3 programming language that includes a small subset of the Python standard library and is optimised to run on microcontrollers and in constrained environments. It aims to be as compatible with normal Python as possible to allow you to transfer code with ease from the desktop to a microcontroller or embedded system.
Also, I think that using Jupyter Notebook to program an ESP device using MicroPython, can be a great tool to teach Physical Computing to kids and also help scientists to quickly access real-world playing with sensors on acquiring data.
This is what we will try to accomplish in this tutorial:
Outputting a digital signal to Turn On/Off a LED
Reading a digital input from a button
Output a PWM signal to fade a LED
Controlling a Servo Motor position using a PWM output
Reading Analog signal (Luminosity using LDR )
Reading Temperature vai 1-Wire (DS18B20)
Reading Temperature and Humidity (DHT22)
Displaying data using an OLED via I2C bus.
The first thing to be done with a fresh NodeMCU (or ESP32), is to erase what is loaded in its memory, “flashing” a new firmware, that will be the MicroPython interpreter.
A. Getting the new FW:
Go to the site: MicroPython downloads and download the appropriated FW for your device:
For example, for ESP8266, the latest version is:
esp8266-20180511-v1.9.4.bin (Latest 01Jun18)
(you can find details how to install the FW here)
The ideal is to create a directory where you will work with MicroPython. For example, in case of a mac, starting from your root directory:
cd Documentsmkdir MicroPythoncd MicroPython
B. Move the downloaded ESP8266 firmware to this recently created directory.
At this point: Connect the NodeMCU or ESP32 to your PC using the serial USB cable.
C. Check where is the serial port that is being used by your device using the command:
ls /dev/tty.*
In my case, I got:
/dev/tty.SLAB_USBtoUART
D. Install esptool (tool used to flash/erase FW on devices)
pip install esptool
E. Erase the NodeMCU flash:
esptool.py --port /dev/tty.SLAB_USBtoUART erase_flash
F. Flash the new FW:
esptool.py --port /dev/tty.SLAB_USBtoUART --baud 460800 write_flash --flash_size=detect 0 esp8266-20180511-v1.9.4.bin
Once you have the Firmware installed, you can play with REPL* on Terminal using the command "Screen":
screen  /dev/tty.SLAB_USBtoUART 115200>>> print (‘hello ESP8266’)>>> hello ESP8266
If you are at REPL, use:
[Ctrl+C] to break a pgm and
[Ctrl+A] [K] [Y] to quit and return to the terminal.
* REPL stands for “Read Evaluate Print Loop”, and is the name given to the interactive MicroPython prompt that you can access on the ESP8266. You can learn more about REPL here.
To interact with a MicroPython ESP8266 or ESP32 over its serial REPL, we will need to install a specific Jupyter Kernel.
This is necessary to be done only once.
From Jupyter Documentation website, we can list all “ Community-maintained kernels”. There, we will be sent to :
Jupyter MicroPython Kernel
Once we have Python 3 installed on our machine (in my case it is a Mac), clone the repository to a directory using the shell command (ie on a command line):
git clone https://github.com/goatchurchprime/jupyter_micropython_kernel.git
Next, install the library (in editable mode) into Python3 using the shell command:
pip install -e jupyter_micropython_kernel
This creates a small file pointing to this directory in the python/../site-packages directory and makes it possible to “git update” the library later as it gets improved.
Things can go wrong here, and you might need “pip3” or “sudo pip” if you have numerous different versions of python installed.
Install the kernel into Jupyter itself using the shell command:
python -m jupyter_micropython_kernel.install
This creates the small file “.local/share/jupyter/kernels/micropython/kernel.json” that jupyter uses to reference it’s kernels.
To find out where your kernelspecs are stored, you can type:
jupyter kernelspec list
The Terminal PrintScreen show you the list of kernels that I have installed on my machine. Note that in my case I installed the MicroPython kernel using PIP3 command and so, the Kernel is not in the same directory that the other ones (I got an error when trying to install my kernel using PIP).
Now run Jupyter notebooks:
jupyter notebook
In the notebook click the "New" Notebook button in the upper right, you should see the kernel display name listed: "MicroPython — USB".
On the first cell, you will need to define the port and baud rate that will be used (115200 works fine):
%serialconnect to --port=/dev/tty.SLAB_USBtoUART --baud=115200
As a response, the cell will return:
Connecting to --port=/dev/tty.SLAB_USBtoUART --baud=115200Ready.
And that’s it! When “Ready” appears, you should be able to execute MicroPython commands by running the cells.
Let’s try:
print ('hello esp8266')
You should receive the response of your ESP8266 as on output on the cell:
hello esp8266
As usual, let’s start our journey to Physical computing, “blinking a LED”.
Below the typical pinout of NodeMCU (ESP8266–12E V1.0):
The available pins are: 0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, which correspond to the actual GPIO pin numbers of ESP8266 chip. Note that many end-user boards use their own adhoc pin numbering (marked e.g. D0, D1, ...).
Install a LED on NodeMCU pin 0 (D3) and test it, turning it ON and OFF:
# import library to deal with pins:from machine import Pin# define pin 0 as outputled = Pin(0, Pin.OUT)# define value of "led" as "1" or "True" to turn on the LEDled.value(1)# define value of "led" as "0" or "False" to turn off the LEDled.value(0)# also you can use .on() or .off methods to control the pin:led.on()led.off()
Now, Let’s import a time library and blink the LED:
from time import sleepfor i in range(5):    led.on()    sleep(0.5)    led.off()    sleep(0.5)
The simple sensor data that you can read on a NodeMCU can be got with a push-button.
Let’s install a push-button between pin 13 (D7) and GND as shown in the diagram.
Our push-button will be connected in a way that pin 13 normal state will be “High” (so we will use an internal Pull-up resistor to guarantee this state). When pressed, pin 13 will be “Low”.
# define pin 13 as an input and activate an internal Pull-up resistor:button = Pin(13, Pin.IN, Pin.PULL_UP)# Read button state:print(button.value())
When you run the above cell (w/o pressing the button), the result will be:
1
Pressing the button, run the cell again:
# Read button state:print(button.value())
The result is now:
0
Note that stop pressing the button does not return the “cell value” to “1”. To see “1”, you must run the cell again.
Let’s now do a small program to turn ON the LED only if the button is pressed:
print (button.value())if button.value() == 0:    led.on()else:    led.off()
PWM can be enabled on all pins except Pin(16). There is a single frequency for all channels, with a range between 1 and 1000 (measured in Hz). The duty cycle is between 0 and 1023 inclusive.
Start calling the appropriate library:
from machine import Pin, PWM
several commands are available:
pwm0 = PWM(Pin(0))      # create PWM object from a pinpwm0.freq()             # get current frequencypwm0.freq(1000)         # set frequencypwm0.duty()             # get current duty cyclepwm0.duty(200)          # set duty cyclepwm0.deinit()           # turn off PWM on the pin
Or you can set configure the pin at once:
pwm2 = PWM(Pin(2), freq=500, duty=512)
Let’s dimming the LED connected to Pin 0 from OFF to ON:
from machine import Pin, PWMpwm0 = PWM(Pin(0), freq=1000, duty=0)for i in range (0,1023,20):    pwm0.duty(i)    sleep(0.1)    pwm0.duty(0)    pwm0.deinit()
And how about to control a Servo Motor?
Let’s install a small hobby servo on our NodeMCU as shown in the diagram. Note that I am connecting the Servo VCC to NodeMCU +3.3V. This is OK for this tutorial, but on real projects, you must connect the Servo VCC to an external +5V Power supply (do not forget to connect the GNDs to NodeMCU GND).
The servo data pin will be connected to NodeMCU pin 14 (D5).
Servos usually work with frequency of 50Hz and then a duty cycle between about 40 and 115 will position them from 0 to 180 degrees respectively. A duty cycle of 77 will position the servo on its center value (90 degrees).
servo = PWM(Pin(14), freq=50)
Test the servo on different positions:
# Minimum position (angle 0)servo.duty(40)# Maximun position (angle 180)servo.duty(40)# center position (angle 90)servo.duty(40)
You can also create a simple swapping program to test your servo:
# swipping servostep = 2for i in range (40, 115, step):    servo.duty(i)    sleep (0.1)    step = -1*stepfor i in range (115, 40, step):    servo.duty(i)    sleep (0.1)    servo.duty(77)
Below the result:
I am not using the sonar here, so I will leave to you to develop a code to use it. It’s simple alheady! Try it!
The ESP8266 has a single pin A0 which can be used to read analog voltages and convert them to a digital value. You can construct such an ADC pin object using:
from machine import ADCadc = ADC(0)
Next, you can read the value of A0 pin using:
adc.read()
The Analog pin could be used to read, for example, a variable value got from a potentiometer as a voltage divider. This can be translated as an output for dimming the LED or move the servo to a specific position. You can try it based on what we have learned so far.
Another useful example is to capture data from an analog sensor, as temperature (LM35), ultraviolet (UV) radiation or luminosity using an LDR (“Light Dependent Resistor).
An LDR decreases its resistance when the luminosity increase. So, you can create a voltage divider with an LDR and a resistor as shown in the diagram.
Reading the analog voltage directly over the resistor, we will get a signal directly proportional to luminosity.
Leave the sensor exposed to light and read the ADC value.
Now, cover the sensor and get a lower value.
In my case:
Maximum Light ==> adc value > 850
Minimun Light ==> adc value < 40
The 1-wire bus is a serial bus that uses just a single wire for communication (in addition to wires for ground and power). The DS18B20 temperature sensor is a very popular 1-wire device, and here we show how to use the "onewire" module to read from such a device.
For the following code to work you need to have at least one DS18B20 temperature sensor with its data line connected to GPIO 2 (D4).
You must also power the sensors and connect a 4.7k Ohm resistor between the data pin and the power pin as shown in the diagram.
Import the libraries:
import onewire, ds18x20
Define which pin the 1-wire device will be connected. In our case ==> pin 2 (D4)
dat = Pin(2)
Create the onewire object:
ds = ds18x20.DS18X20(onewire.OneWire(dat))
Scan for devices on the bus. Remember that you can have multiple devices connected at same bus.
sensors = ds.scan()print('found devices:', sensors)
“sensors” is an array with the address of all 1-wire sensors connected. We will use "sensors[0]” to point to our sensor.
Note that you must execute the convert_temp() function to initiate a temperature reading, then wait at least 750ms before reading the value (do not forget to import time library). To read the value use: ds.read_temp(sensors[0]):
ds.convert_temp()time.sleep_ms(750)print(ds.read_temp(sensors[0]))
DHT (Digital Humidity & Temperature) sensors are low-cost digital sensors with capacitive humidity sensors and thermistors to measure the surrounding air. They feature a chip that handles analog to digital conversion and provides a digital interface using only a single data wire. Newer sensors additionally provide an I2C interface.
The DHT11 (blue) and DHT22 (white) sensors provide the same digital interface, however, the DHT22 requires a separate object as it has a more complex calculation. DHT22 have 1 decimal place resolution for both humidity and temperature readings. DHT11 have whole number for both. A custom protocol is used to get the measurements from the sensor. The payload consists of a humidity value, a temperature value, and a checksum.
Connect the DHT22 as shown in the diagram. The data pin will be connected to NodeMCU pin 12 (D6).
To use the DHT interface, construct the objects referring to their data pin. Start calling the library:
from dht import DHT22
Define the appropriated pin and construct the object:
data = DHT22(Pin(12))
Get the Temperature and Humidity values:
data.measure()temp = data.temperature()hum = data.humidity()print('Temp: {}oC'.format(temp))print('Hum:  {}%'.format(hum))
The DHT11 can be called no more than once per second and the DHT22 once every two seconds for most accurate results. Sensor accuracy will degrade over time. Each sensor supports a different operating range. Refer to the product datasheets for specifics.
DHT22 sensors are now sold under the name AM2302 and are otherwise identical.
I2C is a two-wire protocol for communicating between devices. At the physical level it consists of 2 wires:
SCL and SDA, the clock and data lines respectively.
I2C objects are created attached to a specific bus. They can be initialized when created or initialized later on.
First, let’s import the library:
from machine import I2C
Considering a device on Pin 4 (SDA) and Pin 5 (SCL), let’s create an i2c object:
i2c = I2C(scl=Pin(5), sda=Pin(4))
Now, you should scan the I2C bus for eventual devices there. The function below will do this, returning the number of connected devices and its address:
def scanI2c():    print('Scan i2c bus...')    devices = i2c.scan()    if len(devices) == 0:        print("No i2c device !")    else:        print('i2c devices found:',len(devices))    for device in devices:        print("Decimal address: ",device," | Hexa address: ",hex(device))
Let’s install an I2C OLED display on our NodeMCU as shown in the diagram. The display is the SSD 1306 (128 x 64).
Running the scan function:
scanI2c()
We will get as a result that 1device was found at address 0x3c.
This address will be used for an oled object creation as below:
import ssd1306i2c = I2C(scl=Pin(5), sda=Pin(4))oled = ssd1306.SSD1306_I2C(128, 64, i2c, 0x3c)
Some methods to manage the display:
poweroff(), turns off the screen. Convenient for battery operation.contrast(), to adjust the contrastinvert(), invert the colors of the screen (finally white and black!)show(), to refresh the viewfill(), to fill the screen in black (1) or white (0)pixel(), to turn on a particular pixelscroll(), scroll the screen.text(), to display on text at the indicated x, y positionDraw lines hline(), vline() or any line line()Draw a rect rect rectangle() or rectangle filled fill_rect()
Let’s test our display:
oled.fill(0)oled.text("Hello esp8266", 0, 0)oled.show()
And now, let’s display the DHT22 sensor data on put OLED:
data.measure()temp = data.temperature()hum = data.humidity()oled.fill(0)oled.text("Temp:  " + str(temp) + "oC", 0, 0)oled.text("Hum:   " + str(hum) + "%",0, 16)oled.show()
This article deliveres the bits and pieces for you to build a more robust project, using MicroPython as a programming language and Jupyter Notebook as a tool for quick development and analysis.
Of course, if you want to run a program written in MicroPython on a NodeMCU independent of your PC and Jupyter, you must save your code as a “main.py” file for example, in any text editor and download it on your device using: “ Ampy”, that is a utility developed by Adafruit, to interact with a MicroPython board over a serial connection.
Ampy is meant to be a simple command line tool to manipulate files and run code on a MicroPython board over its serial connection. With ampy you can send files from your computer to a MicroPython board’s file system, download files from a board to your computer, and even send a Python script to a board to be executed.
Installation:
sudo pip3 install adafruit-ampy
Ampy is made to talk to a MicroPython board over its serial connection. You will need your board connected and any drivers to access it serial port installed. Then, for example, to list the files on the board run a command like:
ampy --port /dev/tty.SLAB_USBtoUART ls
For convenience, you can set an AMPY_PORT environment variable which will be used if the port parameter is not specified. For example on Linux or OSX:
export AMPY_PORT=/dev/tty.SLAB_USBtoUART
So, from now one, you can simplify commands:
List internal NodeMCU files:
ampy ls
You can read a file installed on your nodeMCU using:
ampy get boot.py
Once you create a file using your text editor (nano for example), you can install it on your NodeMCU using:
ampy put main.py
Now, when you press “Reset” button on your NodeMcu, the program that will run first is “main.py”.
For Windows, please see the Adafruit instructions here.
As always, I hope this project can help others find their way into the exciting world of electronics!
For details and final code, please visit my GitHub depository: Pyhon4DS/Micropython
For more projects, please visit my blog: MJRoBot.org
Saludos from the south of the world!
See you in my next article!
Thank you,
Marcelo
No rights reserved
 by the author.