Building A Custom Prusa Print Companion App

Picture the scene. You've just sent a big multi-hour 3D print to your new Prusa Core ONE+ and then you are called in to a meeting for the next hour. Do you excuse yourself from the meeting to nurse the print? Or do you ask a colleague to keep an eye on things?

Neither!

In this project we will use a Tufty 2350 with the Prusa 3D printer API to create "Prusa Companion" our own app to monitor 3D prints while we are away from our desk.

With our Prusa Companion app you can see the print progress, monitor temperatures and see the state of a print directly from a Tufty 2350 that is worn around your neck.

What You'll Need

The Prusa API

Prusa's 3D printers have an API (Application Programming Interface) which we are going to use to pull live printer data and then format it for viewing on Tufty 2350. APIs make working with data much easier than scraping the details from a website. APIs expose the data in a predetermined format that programmers can use in their apps.

The API exposes numerous datapoints.

  • The printer's state (Idle, Busy, Ready, Attention, Paused, Stopped, Printing).
  • Print time, duration and elapsed time.
  • Bed and nozzle temperatures.
  • The position of the nozzle across all three axes.
  • Printer and flow speeds.

The API pulls data directly from the printer, in this case a Prusa Core One+, via a service running on the printer. Essentially the printer is serving the API details and Tufty 2350 is acting as a client requesting the data.

To use the API, we need access to the printer. That means we need a password. Luckily, this is easy to find on the printer.

There are two ways to get your PrusaLink password. Via the printer's user interface, or via PrusaSlicer.

Via The Printer

  1. From the user interface, navigate to and select Settings. You can use the touchscreen or use the dial. Pressing the dial in will activate the selected icon.
  2. Scroll down to Network and select.
  3. Scroll down to PrusaLink and select.
  4. Scroll down to Password and make a note of the password.

Via PrusaSlicer

  1. Open PrusaSlicer and in the top right, click on Login.
  2. Login with your Prusa details. If you do not have an account, sign up for one now.
  3. In PrusaSlicer, click on Prusa Connect and then Settings.
  4. Look for your PrusaLink API key and click on the orange icon to copy the key. Put this key in a text file for now.

Finding Your 3D Printer's IP Address

  1. From the user interface, navigate to and select Settings. You can use the touchscreen or use the dial. Pressing the dial in will activate the selected icon.
  2. Scroll down to Network and select.
  3. Scroll down to WiFi and select.
  4. Scroll down to IPv4 Address and make a note of the IP.

Coding The Project

We'll be using the typical Badgeware workflow to create an app. First we create a folder inside the apps folder, then we add image assets and then write code that will run when the app is started.

  1. Connect Tufty 2350 to your PC and press RESET twice to enter USB drive mode. Your PC will treat Tufty 2350 just like a typical USB flash drive.
  2. Open your operating system's file manager and navigate to a drive called TUFTY.
  3. Open secrets.py in a text editor and enter your Wi-Fi SSID and password.
  4. Create a new line at the end of the file and enter your PrusaLink API key then save and close the file.

    API_KEY = "YOUR PRUSALINK API KEY HERE"
    

  5. Inside TUFTY drive, open apps and create a new folder called prusa_companion to contain our app.
  6. Open the prusa_companion folder and create a new folder called assets. In here is where we will store our images. Here are the images for you to download.
    1. atten.png: Attention. Used when the printer has a message for the user.
    2. busy.png: Printer Busy. Used when the printer is auto-homing or moving the print head / bed.
    3. finish.png: Print Finished. When the printer has finished a job.
    4. pause.png: Print Paused. Activated when the user presses pause, or there is a manual task for the user to perform.
    5. stop.png: Print Stop. When the user presses STOP on a print, or the printer encounters an issue.
  7. Open your preferred text editor (we are using Thonny) and in a new blank file, we start coding the project.
  8. Save the blank file as __init__.py inside the prusa_companion folder.
  9. Import a series of modules (pre-written libraries of Python code that provide extra functionality) for the project.

    1. wifi: Badgeware specific helper function to use Tufty 2350's onboard Wi-Fi chip to connect to a network and the Internet.
    2. secrets: Where our Wi-Fi SSID, password, and PrusaLink API key are stored.
    3. time: Controls the pace at which the code runs.
    4. datetime: Provides advanced means to manipulate data and time data, in this case timedelta will convert seconds to hours and minutes.
    5. fetch: Badgeware specific helper function that we use to download data from the PrusaLink API.
    import wifi
    import secrets
    import time
    from datetime import timedelta
    import fetch
    
  10. Set Tufty to use a high resolution screen mode. This will use the full 320 x 240 screen resolution.

    badge.mode(HIRES | VSYNC)
    
  11. Ensure that the Wi-Fi is disconnected. This is strictly an optional step, but we found it prudent to ensure that any lingering Wi-Fi connections were dropped, and fresh connections made before moving onward.

    wifi.disconnect()
    
  12. Using a while not loop, check that we are not connected to Wi-Fi. In essence this loop will try to connect to Wi-Fi. Each time it isn't connected, it will blink Tufty's case lights to show that it is doing something.

    while not wifi.connect():       # keep calling connect() until we're online
       badge.caselights(1)
       time.sleep(0.1)
       badge.caselights(0)
       time.sleep(0.1)
    
  13. Print the connection details to the Python Shell, and then keep the case lights permanently on. The print to the Python Shell is a debug step used to confirm Tufty's IP address. The case lights staying on is there to visually identify that we have a Wi-Fi connection.

    print("Connected! IP address:", wifi.ip())
    badge.caselights(1)
    
  14. Create a constant called PRINTER_IP and use it to store the IP address of your printer. A constant is a value that does not change. It is set once and then used, unlike a variable which can be changed throughout the project.

    PRINTER_IP = "YOUR IP ADDRESS HERE"
    
  15. Using a variable status fetch the details from the API using the IP address and your API key. This line will repeat every second, giving us the latest data from the APi.

    status = fetch.url(
       f"http://{PRINTER_IP}/api/v1/status",
       every=1,
       headers={"X-Api-Key": secrets.API_KEY},
    )
    
  16. Create three constants to store the RGB colour values for orange, white and black. The orange is taken from the Prusa branding guidelines, and is specifically the orange used in Prusa branded products.

    ORANGE = color.rgb(252, 109, 9)
    WHITE = color.rgb(255, 255, 255)
    BLACK = color.rgb(0, 0, 0)
    
  17. For each state, we need to load a configuration that stores the image asset, where it is placed on the screen, status text, and the colour of the pen used to write the text. All of this configuration information is stored in a Python dictionary called STATE_SCREENS and we pull values from the dictionary by referring the the value of each state (the key in Python dictionary terms).

    STATE_SCREENS = {
       "FINISHED": {
           "asset": "/assets/finish.png",
           "rect": (0, 0, 320, 158),
           "msg": "Print Finished",
           "pen": WHITE,
           "alpha": 127,
       },
       "ATTENTION": {
           "asset": "assets/atten.png",
           "rect": (85, 0, 150, 131),
           "msg": "ATTENTION: CHECK PRINTER!!",
           "pen": BLACK,
       },
       "STOPPED": {
           "asset": "assets/stop.png",
           "rect": (85, 0, 150, 131),
           "msg": "PRINT STOPPED",
           "pen": BLACK,
       },
       "PAUSED": {
           "asset": "assets/pause.png",
           "rect": (85, 0, 150, 131),
           "msg": "PRINT PAUSED",
           "pen": BLACK,
       },
       "BUSY": {
           "asset": "assets/busy.png",
           "rect": (85, 0, 150, 131),
           "msg": "PRINTER BUSY",
           "pen": BLACK,
       },
    }
    

  18. Create a function called draw_bar which will draw a progress bar across the screen. The function takes a value between 0 and 100 as an argument. The progress bar is displayed at the bottom of the screen, under any other text. Essentially the function takes the value and does the math to create a filled, white rectangle that shows the progress. It also shows the percentage value.

    def draw_bar(value):
     bar_width = screen.width - 20
     bar_height = 20
     bar_x = (screen.width // 2) - (bar_width // 2)
     bar_y = (screen.height // 1.5) - (bar_height // 1.5)
     border = 2
     screen.pen = color.black
     screen.rectangle(0, 0, screen.width, screen.height)
     screen.pen = color.white
     screen.rectangle(bar_x, bar_y, bar_width, bar_height)
     screen.pen = color.black
     screen.rectangle(bar_x + border, bar_y + border,
                 bar_width - border * 2, bar_height - border * 2)
     fill_width = int((bar_width - border * 2) * (value / 100))
    if fill_width > 0:
     screen.pen = color.rgb(252, 109, 9)
     screen.rectangle(bar_x + border, bar_y + border,
                 fill_width, bar_height - border * 2)
    screen.pen = color.white
    screen.text("Print progress: {}%".format(value), bar_x, bar_y - 30)
    
  19. Create a function, draw_idle_screen which takes three arguments (printer_state, temp_bed and temp_nozzle) and displays them on the screen. Setting the pen colour to orange, we write the start of three lines for printer state, print bed temperature and nozzle temperature. Then, we change the pen colour to white and print the values taken from the API.

    def draw_idle_screen(printer_state, temp_bed, temp_nozzle):
       screen.pen = ORANGE
       screen.text("State:", 10, 10)
       screen.text("Bed temp:", 10, 30)
       screen.text("Nozzle temp:", 10, 50)
       screen.pen = WHITE
       screen.text(printer_state, 150, 10)
       screen.text(f"{temp_bed}°C", 150, 30)
       screen.text(f"{temp_nozzle}°C", 150, 50)
    
  20. The next function handles writing the print progress data to the screen, along with calling the draw_bar function to draw the progress bar. We still write the text in orange for the headers, and white for the API data. The key difference here is that the data includes the remaining print time in seconds, and using timedelta we convert that into hours and minutes. You will also spot the if not isinstance loop. This handles any non-integer data which can sometimes appear in the place of the APIs time_remaining data. It is a rare occurrence, so this is more a belt and braces approach.

    def draw_printing_screen(printer_state, temp_bed, temp_nozzle, job):
       progress = job.get('progress', 0)
       draw_bar(progress)
       remain = job.get('time_remaining')
       if not isinstance(remain, int):
           remain = 0
       remain = timedelta(seconds=remain)
       screen.pen = ORANGE
       screen.text("State:", 10, 10)
       screen.text("Bed temp:", 10, 30)
       screen.text("Nozzle temp:", 10, 50)
       screen.text("Time remaining:", 10, 70)
       screen.pen = WHITE
       screen.text(printer_state, 200, 10)
       screen.text(f"{temp_bed}°C", 200, 30)
       screen.text(f"{temp_nozzle}°C", 200, 50)
       screen.text(f"{remain}", 200, 70)
    
  21. Create a function, draw_status_screen to draw the status screen for when a print is finished, paused, stopped or the printer is busy. All of these status start with an orange background, and from there we load the appropriate image. The image for the FINISHED print state use transparency (alpha) to make the flags slightly transparent. We then place the image in the centre of the screen, with the appropriate text below it.

    def draw_status_screen(cfg):
       screen.pen = ORANGE
       screen.clear()
       sprite = image.load(cfg["asset"])
       has_alpha = "alpha" in cfg
       if has_alpha:
           screen.alpha = cfg["alpha"]
       screen.blit(sprite, rect(*cfg["rect"]))
       if has_alpha:
           screen.alpha = 255
       screen.pen = cfg["pen"]
       w, h = screen.measure_text(cfg["msg"])
       x = (screen.width - w) // 2
       y = (screen.height - h) // 2 if has_alpha else (screen.height + h) // 2
       screen.text(cfg["msg"], x, y)
    
  22. The printer function is where the printer's current state is pulled from the API. We get the general state (Printing, Stopped, Paused, Busy, Attention) then the temperature of the print bed and the nozzle.

    def printer(data):
    
  23. Using a try statement, set the font to font.ignore. This is a large font, that clearly displays on the screen. A try statement attempts to run the code within it. It an be linked with error handling and in this case it is used with a finally to ensure that the code cleanly runs.

       try:
           screen.font = font.ignore
    
  24. Add a print function to print the contents of the returned API data. This is a debug step, so it can be skipped, but during development and testing it is handy to see what the API is sending.

           print(data)
    
  25. Create three variables for the printer_state, the temperature of the print bed temp_bed and the nozzle temperature temp_nozzle. These three variables pull information directly from the returned API data using key reference to selectively slice out the exact information that it requires.

       printer_state = data['printer']['state']
       temp_bed = data['printer']['temp_bed']
       temp_nozzle = data['printer']['temp_nozzle']
    
  26. Set the pen colour to black and then clear the screen. By setting the pen colour to black causes the screen to clear to black.

           screen.pen = BLACK
           badge.clear()
    
  27. Using if and else if elif conditional tests, check the printer state and then call the appropriate function to draw the details to the screen. If the printer_state is either IDLE or READY, then the draw_idle_screen function will shows the current printer state, bed and nozzle temperatures. If the state is PRINTING then draw_printing_screen is used to print that information plus the job progress. The final condition captures any other states, drawing the appropriate status screen based on the state (STOPPED, BUSY, PAUSED).

       if printer_state in ("IDLE", "READY"):
           draw_idle_screen(printer_state, temp_bed, temp_nozzle)
       elif printer_state == "PRINTING":
           draw_printing_screen(printer_state, temp_bed, temp_nozzle, data.get('job', {}))
       elif printer_state in STATE_SCREENS:
           draw_status_screen(STATE_SCREENS[printer_state])
       badge.update()
    
  28. Add a finally that has the sole job of just allowing the code to proceed. This could be used to reset the status of Tufty 2350 when the code exits. But it isn't essential.

       finally:
           pass
    
  29. Using a while True loop and an if conditional test on the printer's status, call the printer() function with the argument for the JSON API. Then pause for one second.

    while True:
       if status:
           printer(status.json())
       time.sleep(1)
    
  30. Save the code to __init__.py inside Tufty 2350's prusa_companion folder.

Testing The Code

To test the code, we need a Prusa 3D printer, and something to print. We're printing one of our Badgeware stands in an orange PLA filament (not Prusa orange, sadly).

  1. On Tufty 2350, start the Prusa Companion app. The app will connect to your Wi-Fi and then show the idle screen. We can see the current printer status, bed and nozzle temperatures.
  2. Using PrusaSlicer, send the print to your Prusa 3D printer so that it is ready to print. PrusaSlicer will ask you to confirm that the print area is clear of debris, clean and everything is ready to print.
  3. The Printing status screen will appear as the 3D printer goes through a series of steps before printing.
    1. You may see an `Attention`` screen advising of a firmware upgrade. This will trigger Tufty 2350 to direct the user to the printer.
    2. The printer will heat up the print bed, and we can see the temperature climb on Tufty 2350.
    3. The nozzle will heat up and this is visible in the app.
    4. The Printing screen will appear and show the progress of the print. The progress bar will update as the print continues.
  4. When the print is finished, the Finished screen will appear. To exit this screen we need to press the dial in, and then press HOME.
  5. The Prusa Companion app will now revert to the idle status display.

Complete Code Listing

import wifi
import secrets
import time
from datetime import timedelta
import fetch

badge.mode(HIRES | VSYNC)

wifi.disconnect()
while not wifi.connect():
    badge.caselights(1)
    time.sleep(0.1)
    badge.caselights(0)
    time.sleep(0.1)

print("Connected! IP address:", wifi.ip())
badge.caselights(1)

PRINTER_IP = "192.168.0.187"
status = fetch.url(
    f"http://{PRINTER_IP}/api/v1/status",
    every=1,
    headers={"X-Api-Key": secrets.API_KEY},
)

ORANGE = color.rgb(252, 109, 9)
WHITE = color.rgb(255, 255, 255)
BLACK = color.rgb(0, 0, 0)

STATE_SCREENS = {
    "FINISHED": {
        "asset": "/assets/finish.png",
        "rect": (0, 0, 320, 158),
        "msg": "Print Finished",
        "pen": WHITE,
        "alpha": 127,
    },
    "ATTENTION": {
        "asset": "assets/atten.png",
        "rect": (85, 0, 150, 131),
        "msg": "ATTENTION: CHECK PRINTER!!",
        "pen": BLACK,
    },
    "STOPPED": {
        "asset": "assets/stop.png",
        "rect": (85, 0, 150, 131),
        "msg": "PRINT STOPPED",
        "pen": BLACK,
    },
    "PAUSED": {
        "asset": "assets/pause.png",
        "rect": (85, 0, 150, 131),
        "msg": "PRINT PAUSED",
        "pen": BLACK,
    },
    "BUSY": {
        "asset": "assets/busy.png",
        "rect": (85, 0, 150, 131),
        "msg": "PRINTER BUSY",
        "pen": BLACK,
    },
}


def draw_bar(value):
    bar_width = screen.width - 20
    bar_height = 20
    bar_x = (screen.width // 2) - (bar_width // 2)
    bar_y = (screen.height // 1.5) - (bar_height // 1.5)
    border = 2
    # value is 0-100
    screen.pen = color.black
    screen.rectangle(0, 0, screen.width, screen.height)
    screen.pen = color.white
    screen.rectangle(bar_x, bar_y, bar_width, bar_height)
    screen.pen = color.black
    screen.rectangle(bar_x + border, bar_y + border,
                      bar_width - border * 2, bar_height - border * 2)
    fill_width = int((bar_width - border * 2) * (value / 100))
    if fill_width > 0:
        screen.pen = color.rgb(252, 109, 9)
        screen.rectangle(bar_x + border, bar_y + border,
                          fill_width, bar_height - border * 2)
    screen.pen = color.white
    screen.text("Print progress: {}%".format(value), bar_x, bar_y - 30)


def draw_idle_screen(printer_state, temp_bed, temp_nozzle):
    screen.pen = ORANGE
    screen.text("State:", 10, 10)
    screen.text("Bed temp:", 10, 30)
    screen.text("Nozzle temp:", 10, 50)
    screen.pen = WHITE
    screen.text(printer_state, 150, 10)
    screen.text(f"{temp_bed}°C", 150, 30)
    screen.text(f"{temp_nozzle}°C", 150, 50)


def draw_printing_screen(printer_state, temp_bed, temp_nozzle, job):
    progress = job.get('progress', 0)
    draw_bar(progress)
    remain = job.get('time_remaining')
    if not isinstance(remain, int):
        remain = 0
    remain = timedelta(seconds=remain)
    screen.pen = ORANGE
    screen.text("State:", 10, 10)
    screen.text("Bed temp:", 10, 30)
    screen.text("Nozzle temp:", 10, 50)
    screen.text("Time remaining:", 10, 70)
    screen.pen = WHITE
    screen.text(printer_state, 200, 10)
    screen.text(f"{temp_bed}°C", 200, 30)
    screen.text(f"{temp_nozzle}°C", 200, 50)
    screen.text(f"{remain}", 200, 70)


def draw_status_screen(cfg):
    screen.pen = ORANGE
    screen.clear()
    sprite = image.load(cfg["asset"])
    has_alpha = "alpha" in cfg
    if has_alpha:
        screen.alpha = cfg["alpha"]
    screen.blit(sprite, rect(*cfg["rect"]))
    if has_alpha:
        screen.alpha = 255
    screen.pen = cfg["pen"]
    w, h = screen.measure_text(cfg["msg"])
    x = (screen.width - w) // 2
    y = (screen.height - h) // 2 if has_alpha else (screen.height + h) // 2
    screen.text(cfg["msg"], x, y)


def printer(data):
    try:
        screen.font = font.ignore
        print(data)
        printer_state = data['printer']['state']
        temp_bed = data['printer']['temp_bed']
        temp_nozzle = data['printer']['temp_nozzle']

        screen.pen = BLACK
        badge.clear()

        if printer_state in ("IDLE", "READY"):
            draw_idle_screen(printer_state, temp_bed, temp_nozzle)
        elif printer_state == "PRINTING":
            draw_printing_screen(printer_state, temp_bed, temp_nozzle, data.get('job', {}))
        elif printer_state in STATE_SCREENS:
            draw_status_screen(STATE_SCREENS[printer_state])
        badge.update()
    finally:
        pass

while True:
    if status:
        printer(status.json())
    time.sleep(1)
That's all folks!

Search above to find more great tutorials and guides.

Plasma 2040

Swathe everything in rainbows with this all-in-one, USB-C powered controller for WS2812/Neopixel and APA102/Dotstar addressable LED strip.