Raspberry Pico Badger: Custom App Development for MQTT Message Display and Graph Visualization

admantium

Sebastian

Posted on January 22, 2024

Raspberry Pico Badger: Custom App Development for MQTT Message Display and Graph Visualization

The Raspberry Pico Badger is a consumer product by the Raspberry Pi company Pimoroni. Advertised as a portable badge with a 2.9 inch e-ink display and a Pico W, it’s a portable, programmable, network connected microcomputer.

Following the introduction in the last article, this post explores how to program a new app for the Badger. The app should connect to a local MQTT broker, subscribe to a topic of interest, fetch and show its data. The consumed data are temperature and humidity numerical values, for which the app also draws a graph.

The technical context of this article is MicroPython v1.21 and Badger OS v0.04. All examples should work with newer releases too.

This article originally appeared at my blog admantium.com.

Required Hardware

To follow along, you just need a Raspberry Pico Badger W, which you can find at pimoroni.com or another reseller. You also need an USB cable to connect the Badger to your computer.

MQTT App Requirements

The concrete app that I want to design should be integrated into Badger OS. Its usage should fulfill the following requirements:

  • The app is represented by a dedicated icon in addition to the other apps
  • Once opened, it will show three different screens:
    • stats: connectivity information, received message count, time
    • stream: show the content of the last received message
    • graph: shows the value changes of temperature and humidity
  • When closed, the messages stats should be stored, but the MQTT connection is disabled to save memory for other apps

To enable these features, following technical features need to be implemented:

  • Connect to a local MQTT browser
  • Subscribe to a specific topic
  • Record message statistics for the subscribed topic (number of messages, individual values for temperature and humidity)

Library Research

When starting this project, there was no structured documentation about app development for the Badger OS. Therefore, a combination of research, documentation reading, and source code studying was required.

My first step was to examine relevant MicroPython libraries that might be necessary. I found the following ones:

  • network: Networking layer that abstracts establishing connections and routing. Subclasses for ethernet and Wi-Fi exist.
  • socket: Build-in library for creating network sockets. No specific protocol support is mentioned in the documentation.
  • json: Library for serializing JSON data.

Next was to check the existing RSS feed reader app of the Badger OS to see which concrete libraries are used as well as understanding how the XML messages are parsed. Opening the example file revealed this:

from urllib import urequest
import gc
import qrcode
import badger_os

def parse_xml_stream(s, accept_tags, group_by, max_items=3):
    tag = []
    ...


def get_rss(url):
    try:
        stream = urequest.urlopen(url)
        output = list(parse_xml_stream(stream, [b"title", b"description", b"guid", b"pubDate"], b"item"))
Enter fullscreen mode Exit fullscreen mode

We can see this:

  • The libary urllib is used to open a web address
  • This returns a byte stream which is forwared to parse_xml_stream()
  • This method consumes the byte stream one char at a time

Ok, so we have a working library for handling HTTP requests. And the responses can be converted to a byte stream and then to text.

Next step was to further dig into the documentation for the Badger W. The most relevant are these:

Right at the start of the Badger2040 documentation, the library umqtt.simple is mentioned, which is already included in the Badger OS UF file. This library is also published as micropython-umqtt.simple. A first look at the API examples gives the impression of a well-documented, up-to-date library. With this, the core functional requirements can be covered.

Understanding UI Drawing and Interactions

By studying the official documentation and the other example apps of the Badger OS, I learned about the essential methods for drawing on the screen.

  • Color: Set different shades of grey with display.pen(0), with 0 being black and 15 being white
  • Shapes: You can draw a line, triangle, circle, rectangle and polygon. Also, a free form drawing of individual pixels is possible too
  • Text: Using display.text() to draw on designated x-y coordinates, using one of the built-in fonts

Using these basic capabilities, I developed some helper methods to set the layout of the app page: A header, a component space, and a footer. As a bonus, only the component space display needs to be refreshed, optimizing page refreshes.

The methods are:

def draw_header(update=False):
    display.set_font("bitmap6")
    display.set_pen(0)
    display.rectangle(0, 0, WIDTH, 20)
    display.set_pen(15)
    display.text("MQTT", 3, 4)

def draw_footer(update=False):
    display.set_font("bitmap6")
    display.set_pen(0)
    display.rectangle(0, HEIGHT-25, WIDTH, 20)
    display.set_pen(15)
    display.text("stats", 3, HEIGHT-25, 1)
    display.text("stream", 120, HEIGHT-25, 1)
    display.text("graphs", 220, HEIGHT-25, 1)

def draw_page():
    clear_page()
    draw_header()
    draw_footer()
    display.update()
    draw_component()
Enter fullscreen mode Exit fullscreen mode

And the helper for clearing the component space:

def clear_page():
    display.set_pen(15)
    display.clear()
    display.set_pen(0)

def clear_component_space():
    display.set_pen(0)
    display.partial_update(0, 24, WIDTH, HEIGHT - 48)
    display.set_pen(15)

def show_component_space():
    display.partial_update(0, 24, WIDTH, HEIGHT - 48)
Enter fullscreen mode Exit fullscreen mode

Next I looked again into the RSS reader app to learn about button handling. This part is straight forward: The initial declarations define buttons by reading the state of pre-defined pins, and then a continuous while() loop checks the pressed buttons to trigger application behavior.

The relevant source code parts are these:

# ...

# Display Setup
display = badger2040.Badger2040()
display.led(128)
display.set_update_speed(2)

# ...

# Setup buttons
button_a = machine.Pin(badger2040.BUTTON_A, machine.Pin.IN, machine.Pin.PULL_DOWN)
button_b = machine.Pin(badger2040.BUTTON_B, machine.Pin.IN, machine.Pin.PULL_DOWN)
button_c = machine.Pin(badger2040.BUTTON_C, machine.Pin.IN, machine.Pin.PULL_DOWN)
button_down = machine.Pin(badger2040.BUTTON_DOWN, machine.Pin.IN, machine.Pin.PULL_DOWN)
button_up = machine.Pin(badger2040.BUTTON_UP, machine.Pin.IN, machine.Pin.PULL_DOWN)

# ...
while True:

    if button_down.value():
        if state["current_page"] < 2:
            state["current_page"] += 1
            changed = True

    if button_up.value():
        if state["current_page"] > 0:
            state["current_page"] -= 1
            changed = True

    if button_a.value():
        state["feed"] = 0
        state["current_page"] = 0
        # ...
Enter fullscreen mode Exit fullscreen mode

I can work with this code as-is.

This finishes the preliminary research. Lets start the app development, beginning with creating a MQTT connection.

MQTT Message Handling Essentials

To get started with the APP development, I copied the news.py file, and only kept the imports, global declarations, and everything after display.connect() which establishes the Wi-Fi connection and def draw_page() to show the app.

Right after the display.connect() method, the following code defines an mqtt object, connects to my local MQTT broker, and sends a message.

mqtt = None

try:
    mqtt = MQTTClient(  "", MQTT_BROKER, port = 1883, keepalive = 10000)
    mqtt.connect()

    msg = '{"message":"hello badger")' 
    res = mqtt.publish( topic = MQTT_TOPIC, msg = msg, qos = 0 )
    print(mqtt)
    print("res: " + str(res))
except Exception as e:
    print(e)
Enter fullscreen mode Exit fullscreen mode

Following the log output, I could see this:

MPY: soft reboot
<MQTTClient object at 200164d0>
res: 0
Enter fullscreen mode Exit fullscreen mode

And yes, the connection is successful, and the message is sent to the broker:

Next, I added a topic subscription and callback method.

def mqtt_msg_received(topic, msg):
    print("Received:", msg, "Topic:", topic)
    draw_mqtt_msg_stream_component(msg)

mqtt = None

try:
    mqtt = MQTTClient(  "", MQTT_BROKER, port = 1883, keepalive = 10000)
    mqtt.connect()

    # ...
    mqtt.set_callback(mqtt_msg_received)
    mqtt.subscribe(MQTT_SUBSCRIBE_TOPIC)
    # ...
Enter fullscreen mode Exit fullscreen mode

Running this code shows:

MPY: soft reboot
<MQTTClient object at 200138e0>

Received: b'{"id":"C4:8D:60:6A:6A:0E","rssi":-67,"brand":"SwitchBot","model":"Meter (Plus)","model_id":"THX1/W230150X","type":"THB","tempc":23.6,"tempf":74.48,"hum":55,"batt":100}' Topic: b'openmqtt/ble-c3/BTtoMQTT/C48D606A6A0E'
Enter fullscreen mode Exit fullscreen mode

To finish this push-through the functions, I added a method to print the MQTT message verbatim on the screen:

def draw_mqtt_msg_stream_component(msg):
    clear_component_space()

    display.set_font("bitmap8")
    display.set_pen(0)
    display.text(msg, 0 , 24, wordwrap=WIDTH, scale=1)
Enter fullscreen mode Exit fullscreen mode

This example worked too. Overall, the functional features are fulfilled, and we can start the development feature by feature.

Component MQTT Stats

When the MQTT app opens, the first screen shows general information about the MQTT connection: The broker address, subscribed topic, connection status, message count on this topic, and timing information.

To keep all this information in a state object that is kept even when a new app is loaded, a Python dict object is used:

mqtt_state = {
    "broker": MQTT_BROKER,
    "connection_state": "disconnected",
    "uptime": 0,
    "msgs_received": 0,
    "topic": MQTT_SUBSCRIBE_TOPIC,
    "local_time": "2023-12-01 00:00:00",
    "msg": ""
}
Enter fullscreen mode Exit fullscreen mode

This state object is updated every time a new message arrives: message content and count, as well as time information are calculated anew.

def update_mqtt_state(msg):
    mqtt_state["msg"] = msg
    mqtt_state["msgs_received"] = mqtt_state["msgs_received"] + 1
    mqtt_state["uptime"] = time.ticks_ms()/1000/60

    lt = list(machine.RTC().datetime())
    mqtt_state["local_time"] = "{}-{}-{} {}:{}:{}".format(lt[0],lt[1],lt[2],lt[4],lt[5],lt[6])
Enter fullscreen mode Exit fullscreen mode

The component rendering shows each information at a separate line of text:

def draw_mqtt_stats_component():
    display.set_font("bitmap6")
    clear_component_space()
    display.set_pen(0)

    LINE_HEIGHT = 12
    y = 2 * LINE_HEIGHT

    display.text("> MQTT Broker: {}".format(mqtt_state["broker"]), 0 , y, wordwrap=WIDTH, scale=1)
    y += LINE_HEIGHT

    display.text("> MQTT Status: {}".format(mqtt_state["connection_state"]), 0 , y, wordwrap=WIDTH, scale=1)
    y += LINE_HEIGHT

   # ...

    show_component_space()
Enter fullscreen mode Exit fullscreen mode

It looks like this:

Component Message Streaming

The second screen of the app shows the last received message text. Its rendering method is rather simple:

def draw_mqtt_msg_stream_component():
    clear_component_space()

    display.set_font("bitmap8")
    display.set_pen(0)
    display.text(mqtt_state["msg"], 0 , 24, wordwrap=WIDTH, scale=1)

    show_component_space() 
Enter fullscreen mode Exit fullscreen mode

A greater challenge was to pretty-print the message. With the built-in draw library, you can specify a work-wrap pixel width. But the next line printed all remaining chars of the text, so virtually printing of screen. Therefore, I added manual line breaks after each comma.

def pretty_print(msg):
    return re.sub(",", ",\n", msg)
Enter fullscreen mode Exit fullscreen mode

A remaining challenge is now total lines - there is no scrolling down larger texts.

Component Temperature Graph

The final component was the trickiest, because I could not learn from a built-in app about these kinds of functions. Instead, I looked into the graphics lib and started with small examples to draw the coordinate system, and then to add lines for individual points of the collected temperature data.

Lets start with the coordinate system:

def draw_mqtt_graph_component():
    x = 20
    y = 20    
    display.set_pen(0)
    display.set_font("bitmap8")
    display.line(x, y, x, HEIGHT)
    display.line(x, HEIGHT-30, WIDTH, HEIGHT-30)
Enter fullscreen mode Exit fullscreen mode

The drawing of the temperature graph was challenging, I needed several hours to devise an algorithm that automatically scaled the temperature data into the available pixel grid. Its individual steps are:

  • Determine the maximum number of points to be drawn (screen width minus offsets for coordinate systems, then increases of 10px for each point)
  • Determine the max and min temperature values
  • Iterate the list of temperature measurements, scaling each temperature value to the grid

The complete algorithm is as follows:

def temp_to_point(value, max, offset):
    return min((max - value)*100, 80) + offset

def draw_mqtt_graph_component():
    # ///
    num_values = max(len(mqtt_state["graph"]["temp"]), len(mqtt_state["graph"]["hum"]))
    y_steps = 10
    max_values = int((WIDTH - 20)/y_steps)

    temp_list = mqtt_state["graph"]["temp"][:max_values-1]
    max_temp = max(temp_list) 
    min_temp = min(temp_list)    

    display.text(str(max_temp), 0, 30, scale=1)
    display.text(str(min_temp), 0, 90, scale=1)

    x_offset = 30
    i = 1

    p1_x = temp_to_point(mqtt_state["graph"]["temp"][0], max_temp, x_offset)
    p1_y = 25
    for temp in temp_list:
        p2_x = temp_to_point(temp, max_temp, x_offset)
        p2_y = p1_y + y_steps

        display.line(int(p1_y), int(p1_x), int(p2_y), int(p2_x))

        p1_x = p2_x
        p1_y = p2_y
        i += 1

    print("Points drawn")
Enter fullscreen mode Exit fullscreen mode

To give you an example how temprature data is converted to pixels, here is some log output:

# Draw 21.50
p1_x=30.00 p1_y=235.00 p2_x=30.00 p2_y=245.00
# Draw 21.40
p1_x=40.00 p1_y=265.00 p2_x=40.00 p2_y=275.00
# Draw 20.90
p1_x=40.00 p1_y=275.00 p2_x=90.00 p2_y=285.00
Enter fullscreen mode Exit fullscreen mode

And the graph looks as this:

UI Interactions

Buttons only switch between the app components. A helper method is used to update the state object and re-draw the component only when a change was detected.

def switch_active_component(new_component):
    app_state["component"] is not new_component:
            app_state["component"] = "stats"
            badger_os.state_save("mqtt", app_state)
            draw_component()

while True:
    mqtt.check_msg()

    if button_down.value():
        pass        

    if button_up.value():
        pass

    if button_a.value():
        switch_active_component("stats")

    if button_b.value():
        switch_active_component("stream")

    if button_c.value():
        switch_active_component("graphs")
Enter fullscreen mode Exit fullscreen mode

To handle different UI interactions on each screen, the button presses conditions need to also factor in the current app state.

App Integration

The last part to finish was the first on the requirements list. The apps that are drawn by the Badger OS is handled by this method:

examples = [x[:-3] for x in os.listdir("/examples") if x.endswith(".py")]

def render():
    #...  

    max_icons = min(3, len(examples[(state["page"] * 3):]))

    for i in range(max_icons):
        x = centers[i]
        label = examples[i + (state["page"] * 3)]
        icon_label = label.replace("_", "-")
        icon = f"{APP_DIR}/icon-{icon_label}.jpg"
        label = label.replace("_", " ")
        jpeg.open_file(icon)
        jpeg.decode(x - 26, 30)
        display.set_pen(0)
        w = display.measure_text(label, FONT_SIZE)
        display.text(label, int(x - (w / 2)), 16 + 80, WIDTH, FONT_SIZE)

Enter fullscreen mode Exit fullscreen mode

As you see, it scans the examples folder for Python files, and then uses determines and draws the icons to be drawn. Therefore, I only needed to add an icon and the app file.

Conclusion

The Badger Pico W comes with a 2.9 inch e-ink display, 5 buttons and Wi-Fi connectivity. Its built-in Badger OS is a showcase of different applications, including image rendering, e-book reader, and RSS feed reader. In this article, you learned how to program a new app for the badger. This app periodically connects to an MQTT broker, fetches data, and shows a textual representation as well as drawing a graph from the received data. You also learned how to implement higher-order abstractions for drawing the header, footer and component space as well.

💖 💪 🙅 🚩
admantium
Sebastian

Posted on January 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related