DALL-E Prompt: Please render a black and white sketch of a 16x16 pixel display with a Game of Life glider with the picture only, no observer
Whenever you walk past one of IKEA's OBEGRÄNSAD lamps you will probably see one of the few preloaded animations. It was one of those coincidences that I was asked whether I could help with the realization of a small embedded project. It turned out that there already are a lot of cool people out there willing to share their knowledge and experience:
- ESP32/Arduino hack for the ikea OBEGRÄNSAD led wall lamp - (
ph1p
/ikea-led-obegraensad
) - IKEA OBEGRÄNSAD LED wall lamp game - (
trandi
/IKEAObegransadLEDdisplay
) - IKEA-Matrix gehackt (
MakeMagazinDE
/Obegraensad
)
The stretch goal was to power up the device with Rust for Embedded devices.
But let's start at the beginning and get our hands dirty kind of interactive with MicroPython.
Our Hardware Setup
There are plenty of great videos and tutorials out there on how to open up the device and connect a microcontroller. So we will skip this point and just describe briefly the setup.
We removed the original IC and wired an ESP8266 (WEMOS D1 Mini Pro) to the device as follows:
Lamp | ESP-8266 | GPIO |
---|---|---|
GND | GND | |
VCC | V5 | |
CLA | D8 | 15 |
CLK | D7 | 13 |
DI | D6 | 12 |
EN | D5 | 14 |
KEY | D3 | 0 |
We spent the setup an additional peripheral for more interaction:
ESP-8266 | GPIO | |
---|---|---|
tilt button | D0 | 16 |
Maybe even more cool sensors will be added in the future.
Opinionated MicroPython Setup
You may skip this step if you are already familiar with MicroPython and have your setup.
This setup is based on:
After some iterations, we came up with the following virtual Python environment...
python3 -m venv .
...activate
the Python environment automagically (in the future) with direnv:
echo source bin/activate > .envrc; direnv allow .
Store the port name of the board in the .envrc file (OSX only).
Note: This requires one single microcontroller board connected to the computer.
echo export PORT_NAME=$(ls /dev/cu.usb*) >> .envrc && direnv allow .
Install MicroPython firmware (ESP8266)
Please check Quick reference for the ESP8266 for more documentation.
echo export FIRMWARE=ESP8266_GENERIC-20240222-v1.22.2.bin >> .envrc && direnv allow .
Tip: You might want to check for newer binaries for micropython ESP8266-GENERIC.
curl -O https://micropython.org/resources/firmware/${FIRMWARE?}
Install esptool
to flash the firmware.
pip install esptool
Erase flash with...
esptool.py --port ${PORT_NAME?} erase_flash
afterward, flash the firmware with:
esptool.py --port $PORT_NAME --baud 1000000 write_flash --flash_size=4MB -fm dio 0 ${FIRMWARE?}
Note: We had trouble with
--flash_size=detect 0
and did specify the memory size with--flash_size=4MB -fm dio 0
instead. You might need to adapt the command line according to the hardware you are using. Also, writing problems may occur when applying too high baud rates. Lower values may help.
Install more developer tools
Add Adafruit Ampy (Filemanager)
You cannot use the REPL to upload files to the board. Instead, a file manager is required. Adafruit Ampy is a good choice.
pip install adafruit-ampy
echo export AMPY_PORT='$PORT_NAME' >> .envrc && direnv allow .
Tip: You can use ampy to list files on the board with
ampy ls
.
Note: This works only if MicroPython is already installed on the board.
First contact with the board
screen $PORT_NAME 115200
You'll see the MicroPython REPL:
>>> 1+1
2
>>>
Tip: Exit screen with
Ctrl-a k y
.
Please note the soft reset command is Ctrl-D
.
To (hard) reset the controller from within the REPL, use machine.reset()
...
...or press the reset button on the board.
Pro Tips for the REPL
To post multiline statements, use Ctrl-E
to enter paste mode.
Exit paste mode with Ctrl-D
(this includes execution).
You can print files (for example boot.py
) with:
print(open('/boot.py').read())
Freeze the dependencies
You can freeze your local development dependencies with:
pip freeze > requirements.txt
Enlightening steps with a Pixel
Finally, ready for Rumble! Let's wire the pins to the LEDs and the key button.
from machine import Pin
P_KEY = Pin(0, Pin.IN, Pin.PULL_UP)
With print(P_KEY.value())
you can directly interact with the lamp and check the state of the button.
Ok, but we're not here to just read the state of the button. The display is a bit more tricky to get started:
P_CLA = Pin(15, Pin.OUT)
P_CLK = Pin(13, Pin.OUT)
P_DI = Pin(12, Pin.OUT)
P_EN = Pin(14, Pin.OUT)
Let's print a checkerboard pattern with the Obegränsad:
buffer = [i % 2 for i in range(16*16)]
...and push the buffer to the display:
def push_buffer(buf):
P_EN.on()
for i in range(16):
P_CLA.off()
for j in range(16):
P_DI.value(buf[i * 16 + j])
P_CLK.on()
P_CLK.off()
P_CLA.on()
P_EN.off()
Cool, let's try to move a single pixel in slow motion through the display:
import time
def move_pixel(x, y):
buffer = [0] * 16 * 16
buffer[y * 16 + x] = 1
push_buffer(buffer)
def run():
for i in range(16):
for j in range(16):
move_pixel(j, i)
time.sleep_ms(100)
This experiment didn't work out as expected. We need to map the coordinates to the correct buffer position. We observed the pixel moving from top to bottom in fancy slopes through the 16 panels of the display. After further investigation, we found out that the panels are positioned in 8 rows and 2 columns.
After a few iterations, we (Kudos go to my pair in this case 😜) came up with the following indexes for the 16 panels:
def panel_index(x, y):
px = (15 - x) // 2
if px % 2:
return 2 * px + (y > 7)
else:
return 2 * px + (y <= 7)
def inpanel_index(x, y):
if x % 2 == 0:
return 8 + y % 8
else:
return 7 -y % 8
...and were able to calculate the Look-Up Table (LUT) for the buffer index from the coordinates:
def coords_2_index(x, y):
return panel_index(x, y) * 16 + inpanel_index(x, y)
lut = [ coords_2_index(x, y) for y in range(16) for x in range(16) ]
With this Look-Up Table, the slightly updated program looks like this:
import time
def move_pixel(x, y):
buffer = [0] * 16 * 16
buffer[lut[x * 16 + y]] = 1
push_buffer(buffer)
def run():
for i in range(16):
for j in range(16):
move_pixel(j, i)
time.sleep_ms(2)
...and the pixel went through the cartesian coordinates as expected.
That was heck of a fun and will be continued with Rust and/or more examples in MicroPython in the upcoming sessions...