LightBurn UDP automation + GCode import questions

Hey all,

Recently I’ve been messing around with the UDP send/recv commands that LightBurn supports. It’s been working great! But I do have two questions:

  • Imported GCode settings not used: when I run LOADFILE for a Gcode file, it seems my origin, initial X/Y offsets & power/speed settings are not used, but some default values for the layer it’s imported into. I can change some default settings (via Layer defaults) but not all… especially the initial X/Y offset based on the absolute origin (think like engraving multiple places on the same piece of material). Is there a way to force LB to recognize these settings?

  • Better way to continue to (next) image: right now, once I see the LightBurn status go back to OK from busy (!) + CLOSE, I have to find the LightBurn process and kill it, then re-open the exectutable. Is there a better way to do this? If I try to LOADFILE (once the previous engraving is done) LightBurn gets stuck with a manual pop up asking if I want to save

Thanks for all the help re my previous post & let me know if I can clarify anything - I feel pretty close to getting a working implementation of our idea :slightly_smiling_face:

Is there a reason you are using GCode files? When you load in GCode you will only get the vectors for toolpaths, all in one layer, and the part is centered. Try using ‘User Origin’ mode with the origin pre-set to where you want the job to go.

If possible, it’s better to have the job you want to automate already set up in a LightBurn project file with the absolute positions you require, and load that instead.

Use FORCELOAD, which will suppress the save prompt.

2 Likes

Thanks for the suggestions!

There is no particular reason - originally we tried using a simple gcode converter & our own PySerial loop before we realized we could use LB directly. We’ve already tested with sending PNGs/JPEGs etc via UDP/LB, but again setting the origin is what is we’ve gotten stuck on (which is why we thought to go back to GCode - add in an initial X/Y offset).

Bigger picture: we have a script taking photos over time/doing some post-processing & using the LB UDP interface all via a python script to take said images → engrave them in a grid pattern on a larger board. I will take a look at setting up a LB project file - our other ideas are:

  • Use PySerial to move the controller to some initial X/Y offset before going through the UDP LB interface to start the actual engraving, worried we could run into contention issues with LB itself
  • During the post processing of the images, modify them to include transparent pixels such that the offset is baked into the image eg say we want to put the green image at a given X/Y, add in the red (transparent) pixels before dispatching the LB:

FORCELOAD should work great, thanks! I can’t believe I missed it in the listings here: Full list of LightBurn UDP commands :sweat_smile:

Appreciate the help! Any suggestions will be considered :grin:

Hi Nis,

Thanks for providing the extra context - it’s very interesting!

There are possibly other methods available to make this happen aside from sending your own serial commands or changing the images - I’m mainly thinking here of the direct manipulation of an absolute mode LightBurn project file.

But perhaps if you could attach a couple of the raw image files, and explain what you want the final output of each image to look that might help determine the best approach.

Aside from that, I was thinking about this over the weekend, and here is python script (made with the help of Google Gemini 2.5 Flash) which demonstrates another kind of workaround using coordinates passed to Python. which launches a modified lbrn2 file of a small line at 0% power in order to bring about the offset.

Example use:

Python OffsetXY.py 100 100
Moves the laser to X100 Y00

Python OffsetXY.py 100 100 Circle.lbrn2
Moves the laser to X100 Y00 and then starts Circle.lbrn2 (which must be set up as a Current Position job - save origin to project)

Python OffsetXY.py 100 100 CurrentPositon.lbrn2 skip_second_start
Moves the laser to X100 Y00 and then loads CurrentPositon.lbrn2 but does not start it. This project file must also have a saved origin, and “skip_second_start” is used because we only need it to load in order to make the origin mode switch from Absolute to Current Position.

This last example might be the most useful for you as it should enable you to easily run your image or gcode exactly where you need it.

Here is the python code: OffsetXY.py
import socket
import time
import sys
import xml.etree.ElementTree as ET
import os
import subprocess
import tempfile # For creating temporary files

# --- Configuration ---
UDP_IP = "127.0.0.1"
UDP_OUT_PORT = 19840
UDP_IN_PORT = 19841
LIGHTBURN_EXE_PATH = "C:\\Program Files\\LightBurn\\LightBurn.exe"

# The template file we will modify and then load
OFFSETXY_TEMPLATE = "C:\\temp\\OffsetXY_template.lbrn2"

SHORT_LINE_WIDTH = 1 # This is the offset for X in your create_dynamic_lightburn_project

# --- Helper Functions ---

def send_udp_command(sock_out, sock_in, message, timeout=5):
    """
    Sends a UDP command and waits for a response.
    Returns the received data (bytes) if successful, or None on timeout/error.
    """
    print(f"Sending: {message}")
    sock_out.sendto(message.encode(), (UDP_IP, UDP_OUT_PORT))
    try:
        sock_in.settimeout(timeout)
        data, addr = sock_in.recvfrom(1024)
        print(f"Received: {data} from {addr}")
        return data
    except socket.timeout:
        print(f"Timeout waiting for response from LightBurn for: {message}")
        return None
    except Exception as e:
        print(f"Error during UDP communication for {message}: {e}")
        return None

def create_dynamic_lightburn_project(template_path, target_x, target_y):
    """
    Reads the template, modifies its XForm coordinates, and saves it to a
    temporary file. Returns the path to the temporary file or None on error.
    """
    temp_file_path = None
    try:
        tree = ET.parse(template_path)
        root = tree.getroot()
        shape_element = root.find(".//Shape")
        xform_element = shape_element.find("XForm")

        if shape_element is None or xform_element is None:
            print(f"Error: Required XML elements (<Shape> or <XForm>) not found in {template_path}")
            return None

        # Calculate new X and Y based on the known '1 0 0 1' matrix
        # Note: Using SHORT_LINE_WIDTH as the X offset, Y is direct
        calculated_x = int(target_x - SHORT_LINE_WIDTH)
        calculated_y = int(target_y)

        # Construct the XForm string directly, assuming '1 0 0 1' matrix
        xform_element.text = f"1 0 0 1 {calculated_x} {calculated_y}"
        print(f"Calculated XForm for dynamic line: {xform_element.text}")

        # Create a temporary file to write the modified LBRN data
        with tempfile.NamedTemporaryFile(mode='wb', suffix='.lbrn2', delete=False) as temp_file:
            temp_file_path = temp_file.name
            tree.write(temp_file.name, encoding="UTF-8", xml_declaration=True)
            print(f"Modified LightBurn project saved to temporary file: {temp_file_path}")
        return temp_file_path

    except FileNotFoundError:
        print(f"Error: Template file not found at {template_path}")
        return None
    except ET.ParseError as e:
        print(f"Error parsing XML file {template_path}: {e}")
        return None
    except Exception as e:
        print(f"An unexpected error occurred during LBRN file creation: {e}")
        return None

def execute_lightburn_job_sequence(sock_out, sock_in, filename_to_load, start_job=True):
    """
    Executes a sequence for a LightBurn job: FORCELOAD, (optional) START,
    and polls STATUS until completion.
    Returns True if the job completes successfully, False otherwise.
    """
    print(f"\n--- Processing Job: {filename_to_load} ---")

    # Load the project
    response = send_udp_command(sock_out, sock_in, f"FORCELOAD:{filename_to_load}")
    if response != b'OK':
        print(f"Failed to load '{filename_to_load}' (did not get 'OK').")
        return False

    if start_job:
        # Start the job
        response = send_udp_command(sock_out, sock_in, "START")
        if response != b'OK':
            print(f"Failed to start job for '{filename_to_load}' (did not get 'OK').")
            return False

        # Poll status until finished
        print(f"Waiting for job '{filename_to_load}' to complete...")
        job_done = False
        start_poll_time = time.time()
        # Max 60 seconds for job, adjust as needed for longer jobs
        while not job_done and (time.time() - start_poll_time) < 60:
            response = send_udp_command(sock_out, sock_in, "STATUS", timeout=2) # Shorter timeout for polling

            if response == b'OK':
                job_done = True
                print(f"Job '{filename_to_load}' finished successfully.")
            elif response == b'!':
                print(f"LightBurn is busy with '{filename_to_load}' (job in progress), continuing to wait...")
                time.sleep(2) # Wait before sending next STATUS
            elif response is None:
                print(f"No response from LightBurn for '{filename_to_load}' (possibly disconnected or crashed), retrying...")
                time.sleep(2) # Wait before next attempt
            else:
                print(f"Unexpected STATUS response for '{filename_to_load}': {response}. Retrying...")
                time.sleep(2) # Wait before next attempt

        if not job_done:
            print(f"Job '{filename_to_load}' did not finish within the allowed time or encountered an unrecoverable issue.")
            return False
    else:
        print(f"Skipping START for job: {filename_to_load} as requested.")

    return True

# --- Main Script Logic ---

if __name__ == "__main__":
    # Updated usage message for the new optional arguments
    if len(sys.argv) < 3 or len(sys.argv) > 5:
        print("Usage: python simple_lightburn.py <X_coordinate> <Y_coordinate> [second_filename] [skip_second_start]")
        print("Example 1: OffSetXY.py 100 50")
        print("Example 2: OffSetXY.py 100 50 C:\\temp\\another_design.lbrn2")
        print("Example 3: OffSetXY.py 100 50 C:\\temp\\another_design.lbrn2 skip_second_start")
        sys.exit(1)

    try:
        x_coord = int(sys.argv[1])
        y_coord = int(sys.argv[2])

        second_filename_path = None
        skip_second_start = False

        if len(sys.argv) >= 4:
            second_filename_path = sys.argv[3]
            if not os.path.exists(second_filename_path):
                print(f"Error: Second file '{second_filename_path}' not found. Please provide a valid path.")
                sys.exit(1)

        if len(sys.argv) == 5:
            if sys.argv[4].lower() == "skip_second_start":
                skip_second_start = True
            else:
                print("Invalid fourth argument. Use 'skip_second_start' if you want to skip starting the second job.")
                sys.exit(1)

    except ValueError:
        print("Error: X and Y coordinates must be integers.")
        sys.exit(1)
    except Exception as e:
        print(f"Error parsing arguments: {e}")
        sys.exit(1)

    print(f"Target coordinates for dynamic line: X={x_coord}, Y={y_coord}")
    if second_filename_path:
        print(f"Second file to load: {second_filename_path}")
        if skip_second_start:
            print("Option enabled: Will NOT automatically START the second job.")

    out_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    temp_lbrn_file = None # Initialize outside try block for finally cleanup

    try:
        in_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Allows port reuse
        in_sock.bind((UDP_IP, UDP_IN_PORT))
    except Exception as e:
        print(f"Error binding socket: {e}. Is LightBurn or another app using port {UDP_IN_PORT}?")
        sys.exit(1)

    try:
        # 1. Check and Launch LightBurn if needed
        print("Checking if LightBurn is running...")
        response = send_udp_command(out_sock, in_sock, "PING", timeout=2)
        if response != b'OK':
            print("LightBurn not detected, attempting to launch...")
            try:
                subprocess.Popen([LIGHTBURN_EXE_PATH])
                time.sleep(5) # Give LightBurn some time to start up
                response = send_udp_command(out_sock, in_sock, "PING", timeout=5)
                if response != b'OK':
                    print("Error: LightBurn did not respond after launch. Exiting.")
                    sys.exit(1)
            except FileNotFoundError:
                print(f"Error: LightBurn executable not found at '{LIGHTBURN_EXE_PATH}'. Please check path.")
                sys.exit(1)
            except Exception as e:
                print(f"Error launching LightBurn: {e}")
                sys.exit(1)
        print("LightBurn is ready.")

        # 2. Create and execute the first job (dynamic line)
        temp_lbrn_file = create_dynamic_lightburn_project(OFFSETXY_TEMPLATE, x_coord, y_coord)
        if temp_lbrn_file is None:
            sys.exit(1)

        if not execute_lightburn_job_sequence(out_sock, in_sock, temp_lbrn_file, start_job=True):
            print("First job (dynamic line) failed. Exiting.")
            sys.exit(1)

        # 3. Handle the optional second job
        if second_filename_path:
            if not execute_lightburn_job_sequence(out_sock, in_sock, second_filename_path, start_job=not skip_second_start):
                print("Second job failed. Exiting.")
                sys.exit(1)
            else:
                print("Second job processed successfully.")
        else:
            print("\nNo second job specified. Script finished.")


    except Exception as e:
        print(f"An unexpected error occurred: {e}")
    finally:
        out_sock.close()
        in_sock.close()
        print("Sockets closed.")
        if temp_lbrn_file and os.path.exists(temp_lbrn_file):
            try:
                os.remove(temp_lbrn_file)
                print(f"Cleaned up temporary file: {temp_lbrn_file}")
            except Exception as e:
                print(f"Error cleaning up temporary file {temp_lbrn_file}: {e}")
        print("Script finished.")

And here are the accompanying files (which I had in C:\Temp):

OffsetXY_template.lbrn2 (795 Bytes)
CurrentPositon.lbrn2 (16.3 KB)

1 Like

@NicholasL That’s an interesting idea - basically create a small pre-job to get the laser head to the origin of interest & post-job to return it to the origin using a ‘dummy’ lbrn2 file template. Your python script looks similar to my own :grin:

For an even bigger picture this is part of a project to make what amounts to an automated ‘laser’ photobooth - someone presses a button, picture gets taken & they will get an engraved puck with their own image and the same image will be engraved on a larger wall (so you can see the history of all previous images):

The python Pillow library allows you to add a transparent background very easily via ImageOps.expand - ImageOps module - Pillow (PIL Fork) 11.3.0 documentation. While I haven’t tried it yet, I’m hoping LB ignores transparent pixels - you can imagine in the above image if I’m attempting to engrave on the larger wall I could ‘expand’ the image like in my previous post (ie red = added transparency, green = image, which I think you already got).

Since I am doing some image post processing currently via Pillow it might be the easier route but I’ll take a look into your pre/post LB job as well. Thanks for the suggestions! I’ll let you know if I run into any more issues re LightBurn. Also obviously always willing to hear more ideas.

PS: bemusingly my only other post on this forum was an attempt at deciphering lbrn2 file formatting to do something very similar - ie use python to load files via XML & attempt changing x/y/rotations/etc but for already existing images/files, not dynamic like here :grin: Lbrn2 File Format Questions - origin point? - #5 by Nis

Kind of… but more useful than that, there is no “post-job” to “return it to origin” (I don’t think that is necessary?)

Regarding this method;
Python OffsetXY.py 100 100 CurrentPositon.lbrn2 skip_second_start

There is the dummy “pre-job” to get the laser in the correct position (OffsetXY_template.lbrn2), immediately followed by another dummy “pre-job” (CurrentPositon.lbrn2) that changes the ‘Start From’ mode to ‘Current Position’ (and I think you could use it to setup the ‘Job Origin’ also).

Now, the next gcode or image you FORCELOAD to the center of the page and START will actually run at the physical location the laser is set at.

Then you would just repeat the process when needed…should work in theory?

1 Like

Will try it this weekend & get back to you! :grin: Again, appreciate all the help.

1 Like