This article is also available as PDF document available.
In this episode we use the same hardware as in the previous episode. By expanding the program and changing the philosophy in color management, we get two more control channels and make the brightness control smoother. Look forward to a new episode in the series
MicroPython on the ESP32 and ESP8266
today
Sensor lamp 2.0
The experimental setup on breadboards is the same as in the first part. Nevertheless, I don't want to withhold the hardware and the reasons for the choice of parts from you.
The hardware
An ESP32, an RGB LED, a Neopixel ring and, for starters, three pieces of circuit board make up the hardware for this project. Of course, we also need the appropriate mechanical basis to turn the pure electronics into a usable device, which we will come to later.
For the ESP32 Dev Kit C V4 controller board used, you need two breadboards to develop the circuit, which are plugged together at the side using a power rail. This is the only way to get enough free contact points for the jumper cables.

Figure 1: Setup with ESP32
I used leftover pieces of a circuit board and an alligator clip as touch pads. Small metal cylinders are used for this in the production system.
I use the RGB LED to display the operating status. The colors of the LEDs on the ring can be individually adjusted up and down. The RGB LED shows which color is currently on. There will be a total of five states: red, green, blue, white and on-off.
The series resistors are dimensioned so that together they produce approximately white light. The blue and especially the green LED are much brighter than the red one. This is the reason for the large differences in the resistance values. If you want a brighter display, use smaller ohm values.
The circuit diagram in Figure 2 shows how everything fits together.

Figure 2: Circuit with ESP32 and touchpads
I chose a type 18650 Li-ion cell as the power supply and a battery holder with charging unit and 5V output. I soldered the supply lines for my circuit to the pins of the USB-A socket. This way, I don't need a USB plug and can still use the mechanical switch on the circuit board. It will point towards the bottom of the housing, as will the charging socket at the top of the board in Figure 2.
Because there won't be enough space in the lamp for the breadboards, I have designed a circuit board on which the few individual parts will fit. You can see the layout as PDF file.

Figure 3: Sensor lamp - layout
I used an RGB LED module in the test setup. For the lamp itself, I found one of the LEDs from the range more optimal because of the easier mounting in the lid.

Figure 4: RGB LED with common cathode
The lamp
I have already explained how the lamp is constructed in the first part. In this installment, I would like to focus on the changes in the program and ask you to use the previous episode for the construction of the lamp itself. There you will also find a dimensioned pattern plan.

Figure 5: Sensor Lampy 2.0
The software
For flashing and programming the ESP32:
Thonny or
Signal tracking:
Firmware used for an ESP32:
The MicroPython programs for the project:
timeout.py Non-blocking software timer
touch.py Touchpad module
touchlampy.py Operating program
touchlampy_2.py Operating program expansion stage 1
touchlampy_3.py Operating program expansion stage 2
touchlampy_4.py Operating program expansion stage 3
MicroPython - Language - Modules and programs
For the installation of Thonny you will find here a detailed instructions (english version). There is also a description of how the Micropython firmware (as of 18.06.2022) to the ESP chip burned is burned onto the ESP chip.
MicroPython is an interpreter language. The main difference to the Arduino IDE, where you always and exclusively flash entire programs, is that you only have to flash the MicroPython firmware once at the beginning on the ESP32 so that the controller understands MicroPython instructions. You can use Thonny, µPyCraft or esptool.py for this. For Thonny I have described the process here described here.
As soon as the firmware has been flashed, you can talk to your controller, test individual commands and see the response immediately without having to compile and transfer an entire program first. This is exactly what bothers me about the Arduino IDE. You simply save an enormous amount of time if you can test simple syntax and hardware tests and even try out and refine functions and entire program parts via the command line before you knit a program from it. I also like to create small test programs for this purpose. As a kind of macro, they summarize recurring commands. Entire applications are then sometimes developed from such program fragments.
Autostart
If you want the program to start automatically when the controller is switched on, copy the program text into a newly created blank file. Save this file under main.py in the workspace and upload it to the ESP chip. The program will start automatically the next time the controller is reset or switched on.
Testing programs
Programs are started manually from the current editor window in the Thonny IDE using the F5 key. This is quicker than clicking on the start button, or via the menu Run. Only the modules used in the program must be in the flash of the ESP32.
In between times Arduino IDE again?
If you want to use the controller together with the Arduino IDE again later, simply flash the program in the usual way. However, the ESP32/ESP8266 will then have forgotten that it ever spoke MicroPython. Conversely, any Espressif chip that contains a compiled program from the Arduino IDE or the AT firmware or LUA or ... can easily be provided with the MicroPython firmware. The process is always the same as here described here.
The neopixel ring
I have also written about Neopixelring, how it works and the NeoPixel module in the last post I have already said the essentials. If you would like to find out more about this topic, I recommend reading the chapter of the same name from this episode.
The program extensions
Program-controlled sequence
Let's start with the structure of the program-controlled color sequence. Almost nothing has changed in the preparations. I have formatted the places that have been tweaked in bold in the listing. First, I introduced a sixth channel number and assigned it the signal color yellow.
# touchlampy_2.py
from touch import TP
from machine import Pin
from neopixel import NeoPixel as NP
from time import sleep, sleep_ms
import uasyncio as asyncio
from esp32 import NVS
# rise = asyncio.Event()
# fall = asyncio.Event()
nvs=NVS("config")
tp1=TP(33, 350)
tp2=TP(32, 350)
tp3=TP(27, 350)
neoPin=Pin(13,Pin.OUT)
neo=NP(neoPin,8)
kanal=0 # sw=0, rt=1, gn=2, bl=3, ws=4, ge=5
color=[0,0,0]
def storeColor():
nvs.set_i32("red",color[0])
nvs.set_i32("green",color[1])
nvs.set_i32("blue",color[2])
nvs.commit()
print("Farbcode gesichert",color)
def loadColor():
rt=nvs.get_i32("red")
gn=nvs.get_i32("green")
bl=nvs.get_i32("blue")
print("Farbcode geladen:",[rt,gn,bl])
return [rt,gn,bl]
try:
color=loadColor()
except:
storeColor()
merken=color
gemerkt=False
freeze=color
step=2
m=0
ledR=Pin(2,Pin.OUT,value=0)
ledB=Pin(15,Pin.OUT,value=0)
ledG=Pin(4,Pin.OUT,value=0)
leds=(ledR,ledG,ledB)
# aus rot gruen blau weiss gelb
shapes=((0,0,0),(1,0,0),(0,1,0),(0,0,1),(1,1,1),(1,1,0),)
channels=("red","green","blue","white","yellow")
mode=len(shapes)
program=0
For the program-controlled color sequence, we first define the variables freeze, step and m and the sixth pattern tuple (1,1,0) is added to control the signal LED, red and green result in yellow. The tuple channels tuple is extended by the entry "yellow". To improve the scalability of the channels, we no longer work with constants to specify the number of channels, but determine their number automatically from the length of the tuple shapes. The flag program flag is later used to switch the automatic color change on and off.
The function lum() function without modification. It transfers the current color to the buffer of the NeoPixel object and sends its content to the ring.
def lum():
for i in range(8):
neo[i]=color
neo.write()
Now the module asyncio module comes into play again. We use it to define three functions that take over the touch control and a function that manages the playback of the color sequence. With tp1 we switch through the channels, save the current color in NVS.config and switch the ring to dark. We decode what is to happen in each case via the length of time the pad is touched.
A function that is to represent a process in the asyncio system must be called with async def can be initiated. The process is terminated as soon as the function is exited. We do not want this, so the process runs as an infinite loop. The objects channel and color are subject to potential changes that we need to query elsewhere, so we declare them at the start of the function as global.
The normal sleep_ms() from time we replace with the asyncio-variant asyncio.sleep_ms(). With the line
await asyncio.sleep_ms(10)
we signal to the system that the process may be interrupted at this point in order to serve other processes with time windows.
The basic operation of the function switchChannel() function is already described in the first part explained. Only in the ring counter for incrementing the control channel number has the constant 5 been replaced by the variable mode has been replaced.
async def switchChannel():
global kanal, color
while 1:
await asyncio.sleep_ms(10)
if tp1.touched():
tp1.getDuration()
hold=tp1.dauer()
print("Dauer",hold)
if hold < 500:
kanal = (kanal + 1) % mode
showChannel(kanal)
elif 500 <= hold <2000:
storeColor()
else:
kanal=0
color=[0,0,0]
showChannel(kanal)
lum()
The plausibility check for the channel number in showChannel() is now done via mode.
def showChannel(num):
assert num in range(mode)
for i in range(3):
leds[i](shapes[num][i])
To the function increase() function, the handling of channel 5 has been added. Because the memory contents of program, m and freeze must be accessible outside the function or process, these variables are also defined as global.
async def increase():
global color,merken,gemerkt,program,m,freeze
while 1:
await asyncio.sleep_ms(10)
if tp2.touched():
n=0
print("increase",kanal)
while tp2.touched():
await asyncio.sleep_ms(50)
if 1 <= kanal <= 3:
ptr=kanal-1
col=color[ptr]
if n < 10:
col = col + 1
else:
col = col + 5
col = min(col,255)
color[ptr]= col
print(channels[ptr], color)
lum()
elif kanal == 4:
for i in range(3):
col=int(color[i] + 3)
color[i]=min(col,255)
print("Lumineszenz", color)
lum()
elif kanal == 0:
color=merken
print(color)
lum()
gemerkt=False
elif kanal == 5:
if not gemerkt:
freeze=color
gemerkt=True
m=0
color=[0,0,0]
lum()
program=1
print("auto gestartet",m,freeze)
n+=1
Channel 5 starts the program-controlled colour sequence and can be recognized by the yellow signal LED color. If channel 5 is active increase() has not yet been called up (touch on GPIO32 pad) or if a touch on GPIO27 pad preceded it, then noted on False and the sequence is run through. We remember the color code in freeze and set noted on True. This procedure only allows a single shot and protects against freeze being set to the color "black" = [0,0,0] in another run. The counter variable m is set to 0 and the NeoPixel LEDs are switched off. This completes all the preparations. With the flag program flag, we signal the process that performs the color sequence (function auto()) that it can start processing.
The function decrease(), which can be used to stop the automatic function.
async def decrease():
global color,merken,gemerkt,program,freeze
while 1:
await asyncio.sleep_ms(10)
if tp3.touched():
n=0
print("decrease",kanal)
while tp3.touched():
await asyncio.sleep_ms(50)
if 1 <= kanal <= 3:
ptr=kanal-1
col=color[ptr]
if n < 10:
col = col - 1
else:
col = col - 5
col = max(col,0)
color[ptr]= col
print(channels[ptr], color)
lum()
elif kanal == 4:
for i in range(3):
col=int(color[i] - 3)
color[i]=max(col,0)
print("Lumineszenz", color)
lum()
elif kanal == 0:
if not gemerkt:
merken=color
print(color)
color=[0,0,0]
lum()
gemerkt=True
elif kanal == 5:
if gemerkt:
program=0
gemerkt=False
await asyncio.sleep_ms(50)
color=freeze
print("auto gestoppt",freeze)
lum()
n+=1
The sequence may only run through if the remembered state is True, i.e. if a color was previously stored in freeze. With program = 0, we send the stop signal to the auto() function and reset the flag noted. Then we have to wait until the loop that has just been executed has finished. By await asyncio.sleep_ms(50) we give the process the opportunity to do this. Then we restore the previously set color and send the data to the ring.
Here comes the completely new function auto(), which defines the process for the color sequence. It is also defined asynchronously so that change requests can be made at any time via the touchpads.
The outer while loop encapsulates the process and, as with the other coroutines, acts as an endless loop to ensure that the process does not abort. Through await asyncio.sleep_ms(10) the other processes also get time slices. If now program has the value 1, a new loop run begins. The inner while loop must also give the other processes the opportunity to get hold of a time slice, i.e. await asyncio.sleep_ms(10).
Using the counter variable m we decode which color sequence is to run. With step we can specify the step width in which the color change should take place. Powers of two are very suitable values for this. The color change is achieved by adding or subtracting the step size. We start with a swelling red and add a final green in the next slice via the mixed color yellow. We then reduce the green, increase red to 255 and blue to 127. In the last layer, we reduce the blue LEDs to 0.
async def auto():
global program, m, color
print("Automatik gestartet")
while 1:
await asyncio.sleep_ms(10)
if program==1:
await asyncio.sleep_ms(10)
while program == 1:
await asyncio.sleep_ms(10)
if 0 < m < 256//step:
color[0]=(color[0]+step)
print("\nRunde1")
elif 256//step < m < 512 // step:
color[0]=(color[0]-step)
color[1]=(color[1]+step)
print("\nRunde2")
elif 512 // step < m < 768 // step:
color[0]=(color[0]+ step)
color[1]=(color[1]- step)
color[2]=(color[2]+ step//2)
print("\nRunde3")
elif 768//step < m < 1024//step:
color[0]=(color[0]- step)
color[2]=(color[2]+ step//2)
print("\nRunde4")
elif 1024//step < m < 1280//step:
color[1]=(color[1]+ step)
color[2]=(color[2]- step//2)
print("\nRunde5")
elif 1280//step < m < 1536//step:
color[1]=(color[1]- step)
print("\nRunde6")
elif 1536//step < m < 1792//step:
color[2]=(color[2]- step// step)
print("\nRunde7")
print(m, color)
lum()
m=(m+1) % (1792//step)
In the test phase, I had the counter and the corresponding color combination displayed, but you can safely delete this line when the system is running. Send the current color code to the ring and increase the ring counter. Then we start with the next run, if program is still 1,
We now only need to set up an event loop and the tasks for the processes and start the whole machine. This happens in the coroutine main() and in the following asyncio.run(main()).
async def main():
loop = asyncio.get_event_loop()
loop.create_task(switchChannel())
loop.create_task(increase())
loop.create_task(decrease())
loop.create_task(auto())
loop.run_forever()
lum()
asyncio.run(main())
The random color sequence
The greatest chaos is probably caused by three colors because the RGB LEDs of the ring are switched up and down at different speeds at different times and for different durations. What could be better suited to this than the parallel operation of three processes that access the coloring more or less randomly. The coroutines of asyncio.
It is now of secondary importance whether we introduce a new channel for this approach or simply use the auto(), which we have just discussed, with a new one. I have chosen the second approach so that no further changes need to be made to the program. I'm sure you won't find it difficult to create a new channel. After all, we have just gone down this path.
So let's create a new function auto(), but this time with a parameter transfer. The argument passed should be the color index, the value 0, 1 or 2 for red, green and blue.
The interesting thing about this approach is that one function definition can be used for all three processes. There will therefore be three instances of the coroutine car(), which work independently of each other. But all three processes access the same objects program and color to. However, each process will only change its share of the color code. However, all three are based on the value in the flag program.
Let's consider which parameters can influence the color sequence. We will then roll suitable random numbers for these parameters. The module randommodule, which we have to take into account in the imports above.
import random
We describe the maximum luminosity with lumMaxthen there is the step size step, the holding time duration and the direction direction. The local variable col contains the color code and with steps we define the maximum number of passes. We obtain this value by dividing the maximum luminosity by the step width, as an integer of course. Depending on the direction rolled col must either be pre-assigned with 0 - when counting up or with lumMax - when counting down. The passage counter m to 0. Assign the color and send it to the ring, then start the inner loop.
async def auto(index):
global program, color
print("Random gestartet")
while 1:
await asyncio.sleep_ms(10)
if program==1:
await asyncio.sleep_ms(10)
lumMax=random.randint(0,160)
step=random.randint(1,5)
duration=random.randint(10,100)
direction=random.getrandbits(1)
col=0 if direction==1 else lumMax
steps=lumMax // step
m=0
color[index]=col
lum()
while program == 1:
await asyncio.sleep_ms(duration)
if direction==1:
col=col+step
col=min(col,lumMax)
else:
col=col-step
col=max(col,0)
color[index]=col
lum()
print(index, m, color)
m += 1
if m >= steps:
break
The current color intensity is duration milliseconds. We increase the color value when direction is 1, but make sure that lumMax is not exceeded. In descending order, the value must not fall below 0.
We add the new value to the list color list and send the code to the ring. If everything works as desired, the print-line can be removed.
The pass counter is incremented and when the number of permissible steps is reached, we exit the inner loop. If program is still 1, we roll the dice again and start with the new settings.
The coroutine auto() is now assigned to a task with the color index 0, 1 or 2.
async def main():
loop = asyncio.get_event_loop()
loop.create_task(switchChannel())
loop.create_task(increase())
loop.create_task(decrease())
loop.create_task(auto(0))
loop.create_task(auto(1))
loop.create_task(auto(2))
loop.run_forever()
lum()
A smoother color management
A better solution for raising and lowering a color mixing tone is still pending. The problem so far is that when increasing the brightness, the color code (255,255,255) is reached at some point and the original setting of the color tone is lost.
In this new approach, I set the desired light color. I evaluate the achieved brightness as 100%. If channel 4 is now selected, the brightness can be reduced in one hundred steps using the plus/minus pads. This is achieved by introducing a factor for the brightness, which is taken into account when calculating the component values.
Of course, this requires an extended function lum() function and some adjustments in various places. The passages are in the overall listing of the program touchlampy_4.py bold formatted in bold.
# touchlampy_4.py
from touch import TP
from touch import TP
from machine import Pin
from neopixel import NeoPixel as NP
from time import sleep, sleep_ms
import uasyncio as asyncio
from esp32 import NVS
import random
nvs=NVS("config")
tp1=TP(33, 350)
tp2=TP(32, 350)
tp3=TP(27, 350)
neoPin=Pin(13,Pin.OUT)
neo=NP(neoPin,8)
kanal=0 # sw=0, rt=1, gn=2, bl=3, ws=4, ge=5
color=[0,0,0] # rt, gn, bl
lumi=100
col=[0,0,0]
def storeColor():
nvs.set_i32("red",color[0])
nvs.set_i32("green",color[1])
nvs.set_i32("blue",color[2])
nvs.set_i32("lumi",lumi)
nvs.commit()
print("Farbcode gesichert",color,lumi)
def loadColor():
global color, lumi
rt=nvs.get_i32("red")
gn=nvs.get_i32("green")
bl=nvs.get_i32("blue")
lumi=nvs.get_i32("lumi")
print("Farbcode geladen:",[rt,gn,bl],lumi)
color=[rt,gn,bl]
try:
loadColor()
except:
storeColor()
merken=color
merkenL=lumi
gemerkt=False
step=2
m=0
ledR=Pin(2,Pin.OUT,value=0)
ledB=Pin(15,Pin.OUT,value=0)
ledG=Pin(4,Pin.OUT,value=0)
leds=(ledR,ledG,ledB)
# Signal: aus rot gruen blau weiss gelb
shapes=((0,0,0),(1,0,0),(0,1,0),(0,0,1),(1,1,1),(1,1,0),)
channels=("red","green","blue","lumi","auto")
mode=len(shapes)
program=0
def aus():
global color
color=[0,0,0]
lum()
def lum(lumi):
global col
for i in range(3):
col[i]=int(color[i]/100*255*lumi/100)
for i in range(8):
neo[i]=col
neo.write()
async def switchChannel():
global kanal, color
while 1:
await asyncio.sleep_ms(10)
if tp1.touched():
tp1.getDuration()
hold=tp1.dauer()
print("Dauer",hold)
if hold < 500:
kanal = (kanal + 1) % mode
showChannel(kanal)
elif 500 <= hold <2000:
storeColor()
else:
kanal=0
color=[0,0,0]
showChannel(kanal)
lum()
def showChannel(num):
assert num in range(mode)
for i in range(3):
leds[i](shapes[num][i])
async def increase():
global color,merken,merkenL,gemerkt,program,m,lumi
while 1:
await asyncio.sleep_ms(10)
if tp2.touched():
n=0
print("increase",kanal)
while tp2.touched():
await asyncio.sleep_ms(50)
if 1 <= kanal <= 3:
ptr=kanal-1
col=color[ptr]
if n < 10:
col = col + 1
else:
col = col + 5
col = min(col,100)
color[ptr]= col
print(channels[ptr], color)
lum(100)
elif kanal == 4:
if n < 10:
lumi = lumi + 1
else:
lumi = lumi + 5
lumi = min(lumi,100)
print("Lumineszenz", color, lumi)
lum(lumi)
elif kanal == 0:
if gemerkt:
color=merken
lumi=merkenL
print(color)
lum(lumi)
gemerkt=False
elif kanal == 5:
if not gemerkt:
merken=color
merkenL=lumi
gemerkt=True
m=0
color=[0,0,0]
lum(0)
program=1
print("auto gestartet",m,freeze)
n+=1
async def decrease():
global color,merken,merkenL,m,gemerkt,program,lumi
while 1:
await asyncio.sleep_ms(10)
if tp3.touched():
n=0
print("decrease",kanal)
while tp3.touched():
await asyncio.sleep_ms(50)
if 1 <= kanal <= 3:
ptr=kanal-1
col=color[ptr]
if n < 10:
col = col - 1
else:
col = col - 5
col = max(col,0)
color[ptr]= col
print(channels[ptr], color)
lum(100)
elif kanal == 4:
if n < 10:
lumi = lumi - 1
else:
lumi = lumi - 5
lumi = max(lumi,0)
print("Lumineszenz", color, lumi)
lum(lumi)
elif kanal == 0:
if not gemerkt:
merken=color
merkenL=lumi
print(color)
color=[0,0,0]
lum(0)
gemerkt=True
elif kanal == 5:
if gemerkt:
program=0
gemerkt=False
await asyncio.sleep_ms(50)
color=merken
lumi=merkenL
print("auto gestoppt",merken)
lum(lumi)
n+=1
async def auto(index):
global program, color
print("Random gestartet")
while 1:
await asyncio.sleep_ms(10)
if program==1:
await asyncio.sleep_ms(10)
lumMax=random.randint(0,100)
step=random.randint(1,3)
duration=random.randint(50,300)
direction=random.getrandbits(1)
col=0 if direction==1 else lumMax
steps=lumMax // step
m=0
color[index]=col
lum(100)
while program == 1:
await asyncio.sleep_ms(duration)
if direction==1:
col=col+step
col=min(col,lumMax)
else:
col=col-step
col=max(col,2)
color[index]=col
print(index, m, color)
lum(100)
m += 1
if m >= steps:
break
async def main():
loop = asyncio.get_event_loop()
loop.create_task(switchChannel())
loop.create_task(increase())
loop.create_task(decrease())
loop.create_task(auto(0))
loop.create_task(auto(1))
loop.create_task(auto(2))
loop.run_forever()
lum(lumi)
asyncio.run(main())
In this and the last episode we were able to make good use of the features of the ESP32 and MicroPython. The main focus was on the non-volatile storage in the NVS area, the touchpads and the parallel execution of tasks with asyncio. Unfortunately, its little brother, the ESP8266, cannot offer these pleasant features on its own. Nevertheless, with more external equipment, it can be made to deliver the same results with almost the same program. You can find out how this works with the ESP8266 and also with the Raspberry Pi Pico in the next blog episode.
Until then, stay tuned!