Skip to main content

Command Palette

Search for a command to run...

Resin-coated Laptop HUD

System information made available via Raspberry Pico2

Updated
20 min read
Resin-coated Laptop HUD

.noise

hello_friend

It’s 2026 and the world is cosplaying a William Gibson book.

[ .noise cut out in this article ]

Preface

In this project I wanted to build something useful, so after brainstorming for a while, I decided I wanted to go for a HUD. Well, in that matter I’ve already failed - HUD meaning “Head Up Display”, while my display was more of a HDD - “Head Down Display”, although that name would be confusing. But you get the idea, basically providing system information like [ heat ] instead of your usual [ ammo left ] and [ health ].

Also I wanted to break the system up into 2 parts, one of which a swapable backend, and the other a frontend able to display animations. For that case I defined a pseudo-protocol able to tell the frontend which strings and values to display. Building on this, you might be able to mod this into f.e. a game helper, showing you cooldowns / health or whatever. I could provide some help with that, but ultimately this is neither a post about using CheatEngine nor about game hacking. [ Note: At the current state of the project, the gauge bar used to display RAM is hardcoded, but I’m working on a generic version for that ]

The basic idea is to gather information about system heat and used memory at the backend side while displaying data on the frontend side. The frontend is soldered directly to the USB bus of my mainboard, so it can sit directly in the laptop casing. Don’t worry tho, in the end it’s just plain USB - you can just plug the Raspberry Pico2 into your USB slot and the project will work identically. It’s just that I like modding stuff and working with resin, that’s all.

The actual temperature is read from Linux system file /sys/class/thermal/thermal_zoneX/temp. The more thermal_zones are available, the more sensors are build into your computer. For the memory part, we can read from /proc/meminfo.

The display uses with the ssd1306 library. I tried out different libraries but finally used the “default” one. You could swap out the library completely and use a different display as long as your driver supports drawing and writing to the screen ( you have to figure that one out yourself tho, but shouldn’t be too big of a deal )

Since the backend is written in plain C++, I used VSCode like in any other project. The frontend was made with Thonny - a useful IDE for working with micropython. At this point I expect you to have a working Raspberry Pico2 setup, use whatever editor you’re most comfortable with.

You can find the code for both the back- and frontend in my Github repositories:

The backend: https://github.com/Silberlachs/SensorBridge

The frontend: https://github.com/Silberlachs/oled

Backend

Let’s first take a look at the backend. I can’t describe every line of code here, so I’ll focus on the important parts. Everything should be self-explanatory by just looking at the checked-out code. You can compile and start everything with the [ buildrun.sh ] script I added to the project.

The project is made up of several components:

  • TemperatureGrabber

    • Most important component, reads from temperature sensors on the mainboard

    • Tested for Ubuntu, should work in any Linux environment

  • Sensor

    • A single thermal component, part of TemperatureGrabber

    • Updates and returns current temperature

  • MemoryGrabber

    • A Hack I wrote to get current used and maximum available system memory

    • Uses bash to write both values into a file, then reads from that file (quick’n dirty)

  • SerialBridge

    • A (now) compact class to communicate with the RBP via USB

    • RBP is using /dev/ttyACM0 (USB slot 0), you might want to modify this

  • Logger

    • Legacy content from when I tried implementing error-handling and tests

    • Might as well be removed from the project or extended later

    • Is a singleton class 🤡

Main

 TemperatureGrabber* temp = new TemperatureGrabber("/sys/class/thermal/thermal_zone");
    MemoryGrabber* mem = new MemoryGrabber();
    Logger* logger = Logger::getInstance();

    ifstream inFile;
    inFile.open("/sys/class/thermal/thermal_zone0/temp");
    if (!inFile) {
        logger->logToFile("Unable to open default thermal_zone file.");
        exit(1); // terminate with error
    }

    SerialBridge* serialBridge = new SerialBridge("/dev/ttyACM0");

The TemperatureGrabber needs a path to the folder in which our thermal files reside ( remember, everything in Linux is a file ). We will look into this class in a second, for now we can assume that at least 1 thermal sensor is available, so we check if thermal_zone0 is available and terminate if that fails.

As you can see, the SerialBridge wants to know which USB slot our Rasbperry uses, you might need to modify this. If you are unsure which slot you’re currently using, check Thonny, usually it automatically detects each RBP connected. We could try to write an expanded initialization logic here to autodetect if a device is available, but you’ll be on your own with this.

    //setting screen values : $:CATEGORYNAME
    string payload = "$:TEMP#";
    payload += temp->getSensoryDataInit();  //name of entries

    payload += "$:RAM#";
    payload += " #&:gauge"; //unnamed entry, then special type
    serialBridge->sendData(payload.c_str());

//at this point initialization has finished and we can update data

    while(true){

        payload = temp->getSensoryData();
        payload += mem->getSystemMemory();
        serialBridge->sendData(payload.c_str());
        std::this_thread::sleep_for(2500ms);
    }

For our pseudo-protocol, I decided to for a simple string-based payload.

  • A category is opened via [ $:NAME# ] ( trailing # symbol )

  • Entries are separated via the [ # ] symbol

  • Unnamed entries can also be created via [ # # ] ( seen in second block )

  • Special type entries are created via [ & ] symbol, but currently not implemented

  • Categories and entries should avoid using control symbols in their names [ $,&,# ]

We put all this data together into a long string, then use our SerialBridge to send it to the RBP after casting it to a c_str. This payload serves as initializing value for the frontend and can be used to rearrange the view during runtime.

Afterwards, in the loop, we are just sending the raw values, again seperated by [ # ]. It is mandatory to send the values in the same order in which we defined our view!

TemperatureGrabber

    TemperatureGrabber::TemperatureGrabber(string path)
    {
        this->path = path;
        while(true)
        {
            if (isFileAvailable())
            {
                string heat, heatname;
                string zoneStr = (path + to_string(sensorCount) + (string)"/").c_str();

                ifstream HeatFile(zoneStr + "/temp");
                ifstream HeatNameFile(zoneStr + "/type");

                getline (HeatFile, heat);
                getline (HeatNameFile, heatname);

                Sensor *sensor = new Sensor(heatname, zoneStr);
                sensor->updateTemperature(stoi(heat));
                sensors.push_back(sensor);

                HeatFile.close();
                HeatNameFile.close();
            }
            else
            {
                break;
            }
            sensorCount++;
        }
    }
    /* checks whether file with temperature information is available */
    bool TemperatureGrabber::isFileAvailable()
    {
        return (stat((path + to_string(sensorCount) + (string)"/").c_str() , &sb) == 0);
    }

In our constructor, we first check how many thermal sensors are available on our mainboard. Since thermal sensors are mapped out as “zones”, we check how many “thermal_zoneX” we find. Since it is not guaranteed that we find any , we check availability in main.cpp aswell (we already looked at that)

The check itself can easily be modified to check for any file as long as you know the path of it. Here, we also cast the current iteration index into the path before checking with the &sb stat struct.

    string TemperatureGrabber::getSensoryDataInit()
    {
        string payload;
        for(int l=0; l< sensors.size(); l++)
        {
            payload = payload + sensors[l]->getName() + "#";
        }
        return payload;
    }

    string TemperatureGrabber::getSensoryData()
    {
        string payload;
        for(int l=0; l< sensors.size(); l++)
        {
            payload = payload + to_string(sensors[l]->getTemperature()/1000) + "#";
        }
        return payload;
    }

We also need these 2 functions, one to get the name of the thermal sensor and one for actual values. The name is only used to initialize the sensor and uses vendor-specific names, so we have to trim them down later in the frontend. The values are updated regularly inside the sensor class and have to be seperated by [ # ] symbol, so we might as well add that symbol before returning the values.

Sensor

The sensor is made up mostly of getters and setters, with the exception of the last function that is used for initialization. The most important part of this class is

    int Sensor::getTemperature()
    {
        ifstream HeatFile(path + "/temp");
        getline (HeatFile, heat);
        HeatFile.close();

        return stoi(heat);
    }

Since we already have the path to each sensor, we can just read from that file and return the value. Of course this is insane! If you want to further enhance the code, there should be a check here. But since I’m lazy and have already confirmed the existence of the sensor, I’ll gladly risk crashing here. Go on and optimize this if you want, but for this project it’s gudnuff. We have to cast the value to integer before returning, or we won’t be able to divide it in TemperatureGrabber ( before re-casting it to string, lul ).

MemoryGrabber

For the memory grabber, I tried keeping it as simple as possible. However, this still included some back-magic shellcode embedded in our c++ framework. Let’s look at the part of the class that does the work

    void MemoryGrabber::getAvailableMemory()
    {
        system("cat /proc/meminfo | grep -E 'MemTotal|MemAvailable' | rev | cut -f2 -d' ' | rev > tmp.txt");
    }

    string MemoryGrabber::readTempFile()
    {
        memory = "";
        try{
            ifstream in("tmp.txt");    

            getline(in,tmp);
            memory = tmp;
            getline(in,tmp);
            memory += "#";
            memory += tmp;
            in.close();
        }
        catch(const std::exception& e){
            return "-1:ERROR";
        }
        return memory;
    }

We are pulling system memory from [ /proc/meminfo ]. However, if we just pulled that and grep the memory, we’d get an output like this:

So in order to process this data (and avoid using extensive c++ functions, we end up with the line of bash you can see in the code box. Basically these steps are required:

  1. pull system memory

  2. grep total and available memory

  3. reverse output (mirroring it)

    • we need this so the space character is on the left;

    • there are too many spaces before the actual values

  4. we split each line with the [ ‘ ‘ ] operator, keeping only the second entry

    • if we had not reversed order, there would have been many entries containing null
  5. reverse again so the original order is restored

  6. write values into a small temp file

The final command is

cat /proc/meminfo | grep -E 'MemTotal|MemAvailable' | rev | cut -f2 -d' ' | rev > tmp.txt

After that, we can just read both entries from the file in the second function, concatenating them with the seperator symbol [ # ]. If the file could not be created or anything went wrong, we just return [ -1: ERROR ]

There is definitely room for improvement here one could argue. The shell could be processed inside a thread, pulling the information directly into process memory, but for now quick and dirty it is.

SerialBridge

The SerialBridge class is composed of a fstream that handles communication with the RBP via serial. It is relatively easy, the only thing we need to do is flushing the connection after each write.

    SerialBridge::SerialBridge(string deviceName)
    {
        this->deviceName = deviceName;
        picoConnection.open("/dev/ttyACM0");
    }

    SerialBridge::~SerialBridge()
    {
        picoConnection.close();
    }

    void SerialBridge::sendData(string message)
    {
        picoConnection << message + "\n";
        picoConnection.flush();
    }

As you can see, I hardcoded the RBP to [ ttyACM0 ], because it is hardwired into my Laptop and will always claim the same port at system startup. However, you might want to change that, especially if you use multiple RBP on the same bus system.

Frontend

Now let’s get to the fun part! The frontend combines embedded development, animation programming and protocol implementation. As I already stated, I am not going to cover how you include libraries and set your microcontroller to autostart mode.

The frontend is made up from the following components:

  • main

    • initializing sub-classes, looping through the working cycle
  • USBridge

    • receiving messages from the backend, polling input so it won’t block
  • GuiBuilder

    • Responsible for showing and playing animations

    • Functions for building the main HUD

    • Updating and repainting values

  • LoadingScreen

    • Special animation shown during initialization

    • controlled by GuiBuilder

    • Animation “playground” using a spritesheet for animating

  • imagelib

    • Memory section that returns bitmap images and resolution

    • images need to be converted into byte arrays ( here is a converter )

The whole frontend is written in [ micropython ]. Since I only used c++ and assembler to program microcontrollers before, I wanted to try out something new. And micropython really didn’t disappoint me. It’s easy to grasp and straightforward, with the only caveat that debugging wasn’t that easy and I kind of locked myself into using Thonny - however the IDE is really nice and useful for (smaller) projects.

Let’s dive into the code.

Main + USBridge

The main class, unsurprisingly, spawns the other objects and initializes our libraries SPI and SSD3601. I am using the display in [ SPI ] mode, but you can also use [ I²C ]. It shouldn’t be too hard to switch protocols, however [ SPI ] is faster. On the downside, [ SPI ] uses 4 wires while [ I²C ] only uses 2 - this might important for your project, for mine 4 wires are fine.

if __name__ == "__main__":
    spi = SPI(0, 100000, mosi=Pin(19), sck=Pin(18))
    oled = SSD1306_SPI(128, 64, spi, Pin(17),Pin(20), Pin(16)) #WIDTH, HEIGHT, spi, dc,rst, cs
    oled.fill(0)         # clear OLED
    oled.show()            # update OLED

    contrast = 128                                         # 8 bit val: 0 - 255
    oled.contrast(contrast)
    guiBuilder = GuiBuilder(oled, contrast)
    usbridge = USBridge()

    while True:
        main()

You might want to play around with the contrast variable a bit, on my display I noticed visual glitches when the brightness was too high - this might not occur with non-transparent screens.

#poll a message from bus, update animation until next message arrives
def getMsg():

    while True:
        try:
            serialMsg = usbridge.pollInput()

            if(serialMsg != "" and len(serialMsg) > 1):
                return serialMsg

            guiBuilder.update()    #update routine
            oled.show()
            sleep_ms(50)
        except:
            return -1

This function polls messages from serial. Note the update function for guiBuilder, this is where we play our loading animation during wait time.

def main():

    guiBuilder.showLogo()
    guiBuilder.showLoadingScreen()
    payload = getMsg()

    guiBuilder.setAnimationCycle("running")
    guiBuilder.showConMsg()
    sleep_ms(100)

    firstPass = True    #emulate do-while loop

    #main working loop
    while True:
        if not firstPass:
            payload = getMsg()
        else:
            firstPass = False

        data = payload.split("#")

        #we are trying to (re)set GUI
        if(data[0][0] == "$"):
            guiBuilder.drawSystemHUD(data)
            continue

        #hardcoded for project
        guiBuilder.refreshDisplayValues()
        guiBuilder.updateValues(data)

The main worker loop, at first we are showing a logo and playing the loading animation which you can see at this post’s cover image.

The loading screen is rendered in [ showLoadingScreen() ] and repainted during the [ getMsg() ] function. The [ animationCycle ] is set to “running”. We could use this to update different animations, but for now I only use it to switch away from the “waiting” state. If you look at GuiBuilder, you’ll find the “running” state not even used at the moment. You might aswell delete this check, but then the call to update needs to be changed, too ( in getMsg ).

Next comes our main worker loop. Since at the point arriving there, we already received a message from the backend, we skip the first pass in the loop and directly try processing the payload. Of course there are no sanity checks built in right now, so any serial signal currently received will be interpreted as a valid connection - be sure to extend the code if you use it for security-relevant systems or have multiple devices communicating on the same line.

    #read serial communication with linux host
    def __init__(self):

        self.poll_object = select.poll()
        self.poll_object.register(sys.stdin,1)

    #poll incoming data, send back to main
    def pollInput(self):

        if self.poll_object.poll(0):
            payload = ""
            while(1):
                payload += sys.stdin.read(1)
                if(payload[len(payload)-1] == "\n"):
                    return payload[:-1]
        return ""

We register a poll_object on stdin, then repeatedly check it until we receive a payload. Each payload is terminated by newline character [ \n ]. This will break if you try using it in conjunction with different senders on the serial line. If you want to have multiple endpoints, you should use a protocol that addresses each device it wants to talk to. This would look a bit like the WLAN protocoll 802.11 - either broadcast to each device or to a specific device.

There is a cheezy workaround to this problem: Just actually use WLAN. Raspberry Pico2 are available with a wireless chip soldered onto them - the respectable model is called Pico2W - for WLAN (genious, i know). This way you wouldn’t need to poll input and the whole project would be a fair bit smaller. However, you’d loose on connection speed and would also be vulnerable to jamming and other wireless attacks… Decide for yourself (normally a simple display isn’t a valuable attack target tho)

GUIBuilder

The GUIBuilder class is our most sophisticated class and also our longest, so I won’t show it in full here but rather highlight some key areas - don’t fret tho, the code is commented and not that hard to follow.

    def showLoadingScreen(self):
        self.loadingScreen.buildScreen()

    def update(self):
        # unfortunately, no "match" support as of january 2026
        if(self.animationCycle == "waiting"):
            self.loadingScreen.update()

        #if(self.animationCycle == "running"):
        #    print("im running")

        return

At first, I want to look at these 2 functions. GUIBuilder uses another class named [ LoadingScreen ] to show a cute little animation while the backend isn’t connected, and these 2 functions just pass on calls to this class. You can also see the animationCycle here, with the option to insert more animations on demand.

    def showConMsg(self):
        #delete screen
        self.oled.fill(0)
        self.oled.rotate(False)
        self.oled.show()
        sleep_ms(30)

        for charCount in range(0,len(self.conStr)):
            self.oled.text(self.conStr[:charCount],24,32,1)
            sleep_ms(50)
            self.oled.show()

This is a little function I made to display text in a “typewriter-like” style, one character after the next, a bit like in old RPGs from the golden 16-bit era of videogames.

    def drawSystemHUD(self, payload):
        self.displayOrder = payload    #used to refresh values later
        self.oled.rotate(False)
        self.oled.fill(0)

        #leave 1 px between lines or letters will bleed into each other
        lineMultiplier = 9
        currentLine = 0

        for x in range(0,len(payload)):
            if "$:" in payload[x]:
                section = payload[x].split(":")[1]
                name = section.split(",")[0]

                #draw block of 8 px
                self.oled.hline(0, currentLine,   9, 1)
                self.oled.hline(0, currentLine+1, 9, 1)
                self.oled.hline(0, currentLine+2, 9, 1)
                self.oled.hline(0, currentLine+3, 9, 1)
                self.oled.hline(0, currentLine+4, 9, 1)
                self.oled.hline(0, currentLine+5, 9, 1)
                self.oled.hline(0, currentLine+6, 9, 1)

                self.oled.text(name,10,currentLine,1)
                currentLine += lineMultiplier
                continue

            if "&:" in payload[x]:
                continue    ##implement gauge

            self.oled.text("|" + payload[x].replace("_",""),0, currentLine , 1)
            currentLine += lineMultiplier

        self.initialized = True
        self.oled.show()
        return

This function takes our first payload (the one setting the ordering) and creates a design for the display from that. Each character is 8 pixel high by default, so we choose a line-height of 9 pixels so we have a small gab between lines. Without that, lines would sort-of “bleed” into each other because the default-font isn’t that good. However, I was too lazy to create a custom font for that. At function-entry, the payload is already split up into an array, so we first safe that for updating later.

In the following loop, we check each field for special characters [ $ ] and [ & ], since # is already processed at this point. For each [ $ ], we draw a new category on the screen, followed by a small block to visually differentiate the categories.

As you see here, the [ & ] character isn’t fully implemented yet, instead I tried to build the structure for it and hardcode it for now - this way it’s easy to implement other control symbols in the future or build a generic gauge.

Lastly, normal entries are just displayed on screen, prefixed by a [ | ] character to indicate they are sub-entries of the category. Notice that there aren’t any values on the screen yet, only the names of the values that will arrive with the next payload soon.

    #hardcoded
    def updateValues(self,values):

        if self.initialized == False:
            return

        self.oled.text(values[0] + "C",100,8,1)
        self.oled.text(values[1] + "C",100,16,1)
        self.oled.text(values[2] + "C",100,24,1)

        ramOffset = self.displayOrder.index(" ")
        ramOffset = ramOffset * 9
        try:
            total = int(values[3]) / 1000000
            available = int(values[4]) / 1000000
            self.oled.text("[" + str(round(total - available,2)) + "/" + str(round(total,2)) + "]",0,ramOffset,1)

            #get percentage number
            percentage = 100 - round(available / total *100)
            self.oled.text(str(percentage) + "%", 100, ramOffset,1)

            #draw RAM gauge
            #self.oled.vline(12,54,9,1)
            self.oled.vline(13,55,8,1)
            self.oled.hline(14,55,100,1)
            self.oled.hline(14,55,percentage,1)
            self.oled.hline(14,56,percentage,1)
            self.oled.hline(14,57,percentage,1)
            self.oled.hline(14,58,percentage,1)
            self.oled.hline(14,59,percentage,1)
            self.oled.hline(14,60,percentage,1)
            self.oled.hline(14,61,percentage,1)
            self.oled.hline(14,62,100,1)
            self.oled.vline(114,55,8,1)
            #self.oled.vline(116,54,9,1)
        except:
            self.oled.text("input error", 0 ,ramOffset ,1)

        #finally, update
        self.oled.show()

This function places the actual values on the screen.

To get the exact coords of the RAM gauge, we search for the index of the [ “ “ ] empty entry and multiply that with our 9 pixel offset

Above the gauge, we calculate the [ max / current ] string and put that out.

At the gauge part itself, we cascaded some draw commands inside each other. The outer 2 vertical lines draw the start and end line of the box. We can see that the maximum range of the gauge is exactly 100 pixels, which is quite convenient. Note that these lines don’t get redrawn at any point and never move, so a possible optimization would be to “initialize” them inside the [ drawSystemHud ] function we just looked at. However, as they are part of the gauge, I found them to be more logical here. If our drawings at any point had to be optimized, I would start here.

In the same matter, the next inner [ hlines ] are the ceiling and floor of the gauge. And inside these, we actually find the 7 pixel drawing commands. Surely we could use a loop of some sorts here, and in fact I will aim for that when making the gauge part dynamical finally.

    def refreshDisplayValues(self):

        #refresh temperature data (always at x=100)
        for x in range(100,128):
            self.oled.vline(x,0,32,0)

        #refresh ram display, needs to be updated for other projects
        ramOffset = self.displayOrder.index(" ")
        ramOffset = ramOffset * 9
        for x in range(0,8):
            self.oled.hline(0,ramOffset + x,128,0)

        self.oled.hline(14 ,55 ,100 ,0)
        self.oled.hline(14 ,56 ,100 ,0)
        self.oled.hline(14 ,57 ,100 ,0)
        self.oled.hline(14 ,58 ,100 ,0)
        self.oled.hline(14 ,59 ,100 ,0)
        self.oled.hline(14 ,60 ,100 ,0)
        self.oled.hline(14 ,61 ,100 ,0)

This last function is “deleting” parts of the screen so we can redraw the values in the next draw-cycle. Imagine this as refreshing sprites in old GameBoy games or the like. The draw commands aren’t visible immediately, however we can think of a “virtual” screen where each pixel value [ 0 ] or [ 1 ] is stored and written over the actual screen during execution of the [ oled.show() ] command.

imageLib

This file represents a library of pixelated images with their length and width parameters that can be called to return each image.

def get_city():
    img = [0x04, 0x7F, ... ]
    img = bytearray([img[ii] for ii in range(len(img)-1,-1,-1)])
    img_res = [24,10]
    return img,img_res

These images contain a city, cloud, banner and complete spritesheet of a bird flying.

You could of course go even further and reimplement them in a class game object, but didn’t see any upsides for me now.

LoadingScreen

This class handles idle animation before our backend connects, however all kinds of animations are possible and this could even build the foundation of a video game engine.

    def __init__(self, oled):
        self.oled = oled
        self.parrotBuff     = []
        self.cloudBuff         = []
        self.cityBuff        = []

        self.parrotRes         = []
        self.cloudRes        = []
        self.cityRes        = []

        self.particles         = []
        self.cityTilePos    = [144, 129, 113, 97, 81, 65, 49, 33, 17, 1, -15, -24]    #random values

        self.cloudPos         = 15
        self.frameCounter     = 0

    def buildScreen(self):
        buffer,img_res = imageLib.get_loadingScreen()
        header = framebuf.FrameBuffer(buffer, img_res[0], img_res[1], framebuf.MONO_HMSB)
        self.oled.fill(0)
        self.oled.show()
        self.oled.rotate(True)
        self.oled.blit(header, 0, 0)
        self.oled.show()

        #initialize cloud and city here, image never changes
        self.cloudBuff, self.cloudRes     = imageLib.get_cloud()
        self.cityBuff, self.cityRes     = imageLib.get_city()
        self.cloud     = framebuf.FrameBuffer(self.cloudBuff, self.cloudRes[0], self.cloudRes[1], framebuf.MONO_HMSB)
        self.city     = framebuf.FrameBuffer(self.cityBuff, self.cityRes[0], self.cityRes[1], framebuf.MONO_HMSB)

At the top of the class happens a lot of initialization, there is at least one possibility to implement a further class for game objects. Anyway, the frameCounter is responsible to display our animation, the “static” parts of the screen can be initialized here. We load them from our library.

    #update the whole loading screen animations
    def update(self):
        if(self.frameCounter < 16):
            self.frameCounter += 1
        else:
            self.frameCounter = 1
            self.spawnParticle()

        if(self.frameCounter % 8 == 0):
            self.spawnCityTile()

        funcName = "get_birb" + str(self.frameCounter)
        birbFunc = getattr(imageLib, funcName)
        self.parrotBuff, self.parrotRes = birbFunc()
        parrot     = framebuf.FrameBuffer(self.parrotBuff, self.parrotRes[0], self.parrotRes[1], framebuf.MONO_HMSB)

        self.oled.blit(parrot, 50, 10)

        self.updateObjects()
        self.oled.show()
        return

We count 16 frames before resetting to frame 1 and spawning a [ particle ] while [ cityTiles ] spawn every 8 frames.

For the bird, we need to “build” our function via getattr(imageLib, funcName) and then call it. This way we can trail the get_birb function with a number each cycle. It’s easy to imagine a frame cycle that loads multiple objects or even screens, animating them. Before displaying them via [ blit() ], the loaded bytes have to be converted into a [ framebuffer ].

    def updateObjects(self):
        for particle in self.particles:
            self.oled.hline(particle[0],particle[1],16,0)
            #self.oled.hline(particle[0],particle[1]+1,8,0)
            particle[0] += 10
            self.oled.hline(particle[0],particle[1],16,1)
            #self.oled.hline(particle[0],particle[1]+1,8,1)

            if(particle[0] > 128):
                self.particles.remove(particle)

        self.cloudPos     = self.cloudPos + 1
        self.oled.blit(self.cloud, self.cloudPos, 40)

        #we want only 1 for loop to save some cpu time
        for count in range(0,len(self.cityTilePos)-1):
            self.cityTilePos[count] += 2
            self.oled.blit(self.city, self.cityTilePos[count], 0)
            if(self.cityTilePos[count] > 150):
               del self.cityTilePos[0]

        if(self.cloudPos > 128):
            self.cloudPos = -20

        self.oled.show()

This whole function is basically one big position tracker. Every particle’s and every cityTile’s positions as well as the cloud’s position are tracked and updated here. If objects moves further than 128 pixel (150 for a cityTile) to the left, they are despawned from their container.

Notice how [ particles ] are updated 10 “ticks” each cycle, [ cityTiles ] 2 and the [ cloud ] 1. This results in the objects having different speed by moving them more pixels in their respective direction.

At the end of this function and in update we both times call [ oled.show() ] - this is not necessary and could be optimized, however I found images smoother to look at and better to film when double painted..

Final project (video)

Here’s a video of the final project in action.

Let me know whether you built this project, and feel free to fork and develop further if you want to help. Leave a comment if you have any questions or something to say, and thank you for reading ..

[ exit removed from post ]