Obegränsad - A Limited LED Lamp with a Promising Name

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:

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(100)

...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...