Javascript code to use game controller in Easel with no other programs

Hey everyone, this is my first post where I’m not asking for help but rather am looking to share something that others will hopefully find useful.

An upgrade I’ve been looking into is getting a USB game controller to control my XCP in Easel. You usually need a controller mapping software, or you’d have to use UGS, so I watched Steve Good’s neat youtube video where he used xPadder and Autohot key to get his setup to work, and it seems to work well, but there were a couple limitations:

  1. The window must always be in the same position (e.g. maximized) so the buttons always fall in the correct location on the screen.
  2. It doesn’t let you take advantage of the controller’s analog thumb stick to jog at different distances/sensitivities based on the analog position, and sometimes I just want to quickly get the spindle out of my way NOW without reaching for the mouse and navigating to the jog distance panel.

I started thinking: javascript natively has an API to communicate with your USB controller (for web gaming), so I decided to write a javascript hack that doesn’t require any external software, is agnostic to the window size / location, and where the inputs from my analog thumb switch determine the jog distance. And then this idea took on a life of its own.

Below is the code, it works perfectly for my $19 Logitech F310 controller, and you’re all obviously welcome to use it, but I highly advise you to be very careful and do lots of testing for your USB controller and machine to make sure you don’t run your machine into walls. I’d imagine the button names and indices differ from one controller to another, so you’ll want to test, and you might even want to update the code.

How do you use it?
Easy way: Inspect, understand, and then copy the below code, then go to your Easel browser window, enter developer mode (usually right click >> Inspect, or key press Ctrl+Shift+I), and paste the below code into the Console.
More advanced way: create a custom chrome extension out of this code. This way the code will load automatically anytime you access your projects in Easel. That’s what I did. happy to show you how if wanted.

Controls:
Left analog thumb stick controls the X and Y jogs (one axis at a time, can’t do both axes simultaneously for diagonal jogging yet).
Right analog thumb stick up/down direction controls the Z jog. Horizontal direction does nothing. I limited this to jog up to 0.1 inch increments, not 1" because I don’t want it going to fa when playing with the analog stick.

Up/down/left/right buttons (not the analog stick) do single step jogs using the currently set jog distance, same as keyboard arrow keys.
If simultaneously holding the right upper trigger button (RB I think), then the up/down buttons will do a single z-jog (similar to holding Shift key and using keyboard arrows), and for this, the jog distance is not limited to 0.1".

‘A’ button manually loops through the X/Y step intervals.
‘B’ button manually loops through the Z step intervals.
‘X’ button opens/closes the “Jog Machine” dialog box, because similar to keyboard controls, the joystick will only work if this dialog box is open.
‘Y’ button while holding the left upper trigger (LB) homes the machine.

I feel like there’s more that could be done, but that’s what I’ve gotten so far. Thank ChatGPT for doing all the leg work, I was just its boss.

let buttonPressStates = new Array(16).fill(false); // Tracks if a press has been registered for each button
let modalOpen = (document.getElementsByClassName("machine-control-modal").length == 1);

const svgButtonMap = {
    12: 'y-up',    // Up button
    13: 'y-down',  // Down button
    14: 'x-left',  // Left button
    15: 'x-right', // Right button
};

function hitEscapeKey() {
    // Create a KeyboardEvent object for the Escape key
    const escapeKey = new KeyboardEvent("keydown", {
        key: "Escape",
        keyCode: 27, // Deprecated, but included for compatibility with older code
        which: 27, // Deprecated, but included for compatibility with older code
        bubbles: true, // Allow the event to bubble up through the DOM
        cancelable: true // Allow the event to be cancellable
    });

    // Dispatch the event on the document object
    document.dispatchEvent(escapeKey);
}

function cycleThroughButtons(buttons, hasClass) {
    const primaryIndex = Array.from(buttons).findIndex(button => button.classList.contains(hasClass));
    const nextIndex = (primaryIndex + 1) % buttons.length;

    buttons.forEach((btn, index) => {
        btn.classList.remove(hasClass);
        if (index === nextIndex) {
            btn.classList.add(hasClass);
        }
    });
    buttons[nextIndex].click();
}

function handleSingleButtons(index) { //
    switch (index) {
        case 0: // A button - cycle through XY presets
            if (modalOpen) {
                const xyButtons = document.querySelectorAll('.step-interval__presets.xy_presets button');
                cycleThroughButtons(xyButtons, "btn-primary");
            }
            break;
        case 1: // B button - cycle through Z  presets
            if (modalOpen) {
                const zButtons = document.querySelectorAll('.step-interval__presets.z_presets button');
                cycleThroughButtons(zButtons, "btn-primary");
            }
            break;
        case 2: // X button - toggle modal visibility
            if (modalOpen) {
                hitEscapeKey();
                modalOpen = false;
            } else {
                document.querySelector('.machine-control-widget__button.machine-status-indicator').click();
                modalOpen = true;
            }
            break;
    }
}

function handleButtonCombos(combo) {
    let elementToClickId, elementToClick;
    switch (combo) {
        case 1:
            elementToClickId = "z-zUp";
            elementToClick = document.querySelector(`g[id="${elementToClickId}"].jog-buttons__arrow`);
            if (elementToClick) {
                const clickEventSVG = new MouseEvent('click', {
                    bubbles: true,
                    cancelable: true,
                    view: window
                });
                elementToClick.dispatchEvent(clickEventSVG);
            }    
            break;

        case 2:
            elementToClickId = "z-zDown";
            elementToClick = document.querySelector(`g[id="${elementToClickId}"].jog-buttons__arrow`);
            if (elementToClick) {
                const clickEventSVG = new MouseEvent('click', {
                    bubbles: true,
                    cancelable: true,
                    view: window
                });
                elementToClick.dispatchEvent(clickEventSVG);
            }    
            break;
        case 3: // Y button - Home machine (if modal is open)
            const buttons = document.querySelectorAll('.btn.btn-primary.m-1');
            const homeButton = Array.from(buttons).find(button => button.innerText === 'Home');

            if (homeButton) {
                // If the button is found, you can now click it or perform any other actions
                homeButton.click();
            }
            break;
    }
}


// Mapping of gamepad button indexes to corresponding actions or elements


function updateGamepadState() {
    setTimeout(updateGamepadState, 175); // Schedule the next call
    const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];

    for (let i = 0; i < gamepads.length; i++) {
        const gamepad = gamepads[i];
        if (gamepad) {
            let currentButtonStates = gamepad.buttons.map(button => button.pressed);
            if (currentButtonStates[5] && currentButtonStates[12] && !buttonPressStates[12]) {
                handleButtonCombos(1);
                buttonPressStates = currentButtonStates;
            } else if (currentButtonStates[5] && currentButtonStates[13] && !buttonPressStates[13]) {
                handleButtonCombos(2);
                buttonPressStates = currentButtonStates;
            } else if (currentButtonStates[4] && currentButtonStates[3] && !buttonPressStates[3]){
                handleButtonCombos(3);
                buttonPressStates = currentButtonStates;
            } else {
                gamepad.buttons.forEach((button, index) => {
                    if (button.pressed) {
                        if ([0, 1, 2, 3].includes(index)) { // Handle special buttons separately
                            handleSingleButtons(index);
                        } else if (svgButtonMap.hasOwnProperty(index) && !buttonPressStates[index]) {
                            // SVG click logic for jog buttons
                            let elementToClickId = svgButtonMap[index];
                            let elementToClick = document.querySelector(`g[id="${elementToClickId}"].jog-buttons__arrow`);
                            if (elementToClick) {
                                const clickEventSVG = new MouseEvent('click', {
                                    bubbles: true,
                                    cancelable: true,
                                    view: window
                                });
                                elementToClick.dispatchEvent(clickEventSVG);
                            }
                            buttonPressStates[index] = true; // Mark as registered
                        }
                    } else {
                        // Reset the registered press flag when the button is released
                        buttonPressStates[index] = false;
                    }
                });

                const XAxis = gamepad.axes[0];
                const YAxis = gamepad.axes[1];
                const ZAxis = gamepad.axes[3];
                const deadZone = 0.05; // Define a small threshold


                //Left analog stick:
                if (Math.abs(XAxis) > deadZone || Math.abs(YAxis) > deadZone) {
                    // Determine primary axis and direction
                    const isXAxisPrimary = Math.abs(XAxis) > Math.abs(YAxis);
                    const positiveDirection = isXAxisPrimary ? XAxis > 0 : YAxis > 0;
                    const primaryAxisValue = isXAxisPrimary ? Math.abs(XAxis) : Math.abs(YAxis);

                    // Determine step size based on custom scale
                    let index;
                    if (primaryAxisValue <= 0.37) {
                        index = 0;
                    } else if (primaryAxisValue <= 0.63) {
                        index = 1;
                    } else if (primaryAxisValue <= 0.99) {
                        index = 2;
                    } else {
                        index = 3;
                    }

                    // Click the determined step size button
                    const buttons = document.querySelectorAll('.step-interval__presets.xy_presets button');
                    if (index !== undefined & modalOpen) {
                        buttons[index].click();
                    }

                    // Delay to ensure the step size is set before executing the jog command
                    setTimeout(() => {
                        // Selecting the SVG element based on axis and direction
                        let elementToClickId = isXAxisPrimary ? (positiveDirection ? 'x-right' : 'x-left') : (positiveDirection ? 'y-down' : 'y-up');
                        let elementToClick = document.querySelector(`g[id="${elementToClickId}"].jog-buttons__arrow`);

                        // Trigger a click event if the SVG element is found
                        if (elementToClick) {
                            const clickEventSVG = new MouseEvent('click', {
                                bubbles: true,
                                cancelable: true,
                                view: window
                            });
                            elementToClick.dispatchEvent(clickEventSVG);
                        }
                    }, 8); // A short delay to allow sequence of actions
                }

                //Right analog stick
                const ZAxisValue = Math.abs(ZAxis);
                if (ZAxisValue > deadZone) {
                    // Determine direction
                    const positiveZDirection = ZAxis < 0;

                    // Determine step size based on custom scale
                    let indexZ;
                    if (ZAxisValue <= 0.4) {
                        indexZ = 0;
                    } else if (ZAxisValue <= 0.99) {
                        indexZ = 1;
                    } else {
                        indexZ = 2;
                    }

                    // Click the determined step size button
                    const buttons = document.querySelectorAll('.step-interval__presets.z_presets button');
                    if (indexZ !== undefined & modalOpen) {
                        buttons[indexZ].click();
                    }

                    // Delay to ensure the step size is set before executing the jog command
                    setTimeout(() => {
                        // Selecting the SVG element based on axis and direction
                        let elementZToClickId = (positiveZDirection ? 'z-zUp' : 'z-zDown');
                        let elementZToClick = document.querySelector(`g[id="${elementZToClickId}"].jog-buttons__arrow`);

                        // Trigger a click event if the SVG element is found
                        if (elementZToClick) {
                            const clickEventSVG = new MouseEvent('click', {
                                bubbles: true,
                                cancelable: true,
                                view: window
                            });
                            elementZToClick.dispatchEvent(clickEventSVG);
                        }
                    }, 8); // A short delay to allow sequence of actions
                }
            }
        }
    }
}

// Initialize the loop
updateGamepadState();
2 Likes

Thanks for sharing. I’m just getting ready to add a game controller and I will have to see if I can get this to work with what I have.

1 Like

Nice!
If you want to test first, you can use this little code snippet in any browser window just to make sure your controller’s buttons map to the same indexes I’m using above, and avoid surprises:

let prevTimestamps = [];
const logGamepadState = () => {
    const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
    for (let i = 0; i < gamepads.length; i++) {
        const gamepad = gamepads[i];
        if (gamepad) {
            if (prevTimestamps[i] !== gamepad.timestamp) { // Check if the state has been updated
                console.clear();
                console.log(`Gamepad ${i}: ${gamepad.id}`);
                gamepad.buttons.forEach((button, index) => {
                    if (button.pressed) console.log(`Button ${index} pressed`);
                });
                gamepad.axes.forEach((axis, index) => {
                    console.log(`Axis ${index}: ${axis}`);
                });
                prevTimestamps[i] = gamepad.timestamp;
            }
        }
    }
    requestAnimationFrame(logGamepadState);
};
logGamepadState();


Standard mapping indexes:

  1. A
  2. B
  3. X
  4. Y
  5. LB
  6. RB
  7. LT
  8. RT
  9. back
  10. start
  11. left analog stick click
  12. right analos stick click
  13. up button
  14. down button
  15. left button
  16. right button
1 Like

Jon,
This is a bit over my head, but I would like to be able to jog and position with a game controller. I picked one up that my son-in-law is no longer using. It is a PowerA controller for an xbox. Would you mind providing a version for dummies of how I would verify the buttons on this controller function the same, and which values I would change if they are different? I would like to add it as a custom Chrome extension, if you can provide the details of how to do that. Thank you!

Hey Steven, sorry for the delay. The first step is to know what number / index each of your buttons corresponds to. So to find that out here’s what you want to do:

  1. Connect your controller to your pc
  2. Open a new browser window in chrome.
  3. Open the developer console (right click and select Inspect, OR you could press Ctrl+Shift+I on your keyboard). In the upper right corner, find the “Console” and select that.
    In the console, paste this code:
let prevTimestamps = [];
const logGamepadState = () => {
    const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
    for (let i = 0; i < gamepads.length; i++) {
        const gamepad = gamepads[i];
        if (gamepad) {
            if (prevTimestamps[i] !== gamepad.timestamp) { // Check if the state has been updated
                console.clear();
                console.log(`Gamepad ${i}: ${gamepad.id}`);
                gamepad.buttons.forEach((button, index) => {
                    if (button.pressed) console.log(`Button ${index} pressed`);
                });
                gamepad.axes.forEach((axis, index) => {
                    console.log(`Axis ${index}: ${axis}`);
                });
                prevTimestamps[i] = gamepad.timestamp;
            }
        }
    }
    requestAnimationFrame(logGamepadState);
};
logGamepadState();

Then, start pressing buttons on your controller and read what the console says. Each button you press, the console should tell you which “index” or “axis” is being pressed. Write down which button corresponds to which number. example:

A button ==> Index 1
B button ==> Index 2
X button ==> Index 3
Y button ==> Index 4
Left analog X direction ==> Axis 1
Left analog Y direction ==> Axis 2
etc.

Do this for every single button / axis, and then report back. We’ll then make sure the code is tailored to your controller’s specific mapping.

Jon,
Thanks for getting back to me. I followed the above and pasted the script into the console. If I just click a controller button after pasting, nothing happens. If I hit enter, it says “undefined”. If I press a controller button after hitting enter as described above, it just goes into endless scrolling and does not return a value that I can read. Am I missing something? Should I paste the code and then get results when pressing buttons, or should I be pasting, hitting enter and then pressing buttons. Neither seems to work, so I don’t know what I’m doing wrong.

Hey Steven,
after pasting the code, yes you want to hit Enter. After that, whenever you press a button on the controller, the console should log something like “Button 9 is pressed” or whatever that button’s index number is.
Here’s a screenshot of what I get when I hold down the A button on my controller, and I’ve tested this and get the same result with my Logitech F310 controller and my wireless Bluetooth Amazon Luna controller, works the same with both (with a slight difference I’ll come back to later):

After you paste the code, hit Enter, and press the A button on your controller, can you take a screenshot of what you’re seeing logged in your console?


Hopefully the video comes through. I tried this on my work laptop also and got the same result.

Got it, that’s actually really helpful. Notice that your Axis 0 and Axis 2 values are changing. So whatever buttons you’re pressing correspond to those axes.
If you’re not pressing buttons, then another possible explanation is that your analog sticks are highly highly sensitive and are reporting very tiny changes in their values. So I’ve modified the code so there’s a little “threshold” meaning that it doesn’t need to log every 1 trillionth of a movement on the axis value (which I bet is what was causing the relentless refresh rate in your case). Can you try this:


let prevButtonStates = [];
let prevAxisValues = [];
const threshold = 0.03; // Threshold for detecting significant axis movement

const logGamepadState = () => {
    const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
    for (let i = 0; i < gamepads.length; i++) {
        const gamepad = gamepads[i];
        if (gamepad) {
            if (!prevButtonStates[i]) {
                prevButtonStates[i] = new Array(gamepad.buttons.length).fill(false);
            }
            if (!prevAxisValues[i]) {
                prevAxisValues[i] = new Array(gamepad.axes.length).fill(0);
            }
            let stateChanged = false;
            const buttonStates = gamepad.buttons.map((button, index) => {
                const wasPressed = prevButtonStates[i][index];
                const isPressed = button.pressed;
                if (wasPressed !== isPressed) {
                    console.log(`Gamepad ${i}: Button ${index} ${isPressed ? 'pressed' : 'released'}`);
                    stateChanged = true;
                }
                return isPressed;
            });

            const axisStates = gamepad.axes.map((axis, index) => {
                const prevAxis = prevAxisValues[i][index];
                const delta = Math.abs(axis - prevAxis);
                if (delta > threshold) {
                    console.log(`Gamepad ${i}: Axis ${index}: ${axis.toFixed(2)}`);
                    stateChanged = true;
                }
                return axis;
            });

            if (stateChanged) {
                prevButtonStates[i] = buttonStates;
                prevAxisValues[i] = axisStates;
            }
        }
    }
    requestAnimationFrame(logGamepadState);
};
logGamepadState();


I hope this will give better results.

Thank you - I will give it a go when I’m home after work. Much appreciated!

1 Like

That was the trick! Below are the results I got. Please let me know if you have any questions about my button definitions.

Gamepad 1: Axis 1: -0.08 = Left Joy forward
Gamepad 1: Axis 1: 0.10 = Left joy backward
Gamepad 1: Axis 0: -0.04 = Left joy left
Gamepad 1: Axis 0: 0.63 = Left joy right
Gamepad 1: Axis 3: -0.30 = Right joy forward
Gamepad 1: Axis 3: 0.68 = Right joy backward
Gamepad 1: Axis 2: -0.63 = Right joy left
Gamepad 1: Axis 2: 0.35 = Right joy right
Gamepad 1: Button 0 pressed = A
Gamepad 1: Button 1 pressed = B
Gamepad 1: Button 2 pressed = X
Gamepad 1: Button 3 released = Y
Gamepad 1: Button 12 pressed = cross button forward
Gamepad 1: Button 13 pressed = cross button backward
Gamepad 1: Button 14 pressed = cross button left
Gamepad 1: Button 15 pressed = cross button right
Gamepad 1: Button 8 pressed = small left button
Gamepad 1: Button 9 pressed = small right button
Gamepad 1: Button 6 pressed = left trigger
Gamepad 1: Button 7 pressed = right trigger
Gamepad 1: Button 4 pressed = front left button
Gamepad 1: Button 5 pressed = front right button
Gamepad 1: Button 10 pressed = left joy pressed down
Gamepad 1: Button 11 pressed = right joy pressed down

Hey Steven, it looks like your button mappings are identical to the ones in my original post, which is great.

Open an Easel window, and just like you did above, open console and paste the code in my original post, and hit enter.

Then your controller should work. In my original post I explain what each of your buttons will do. Try it, it should work as is.

Awesome- glad to hear that. Assuming that all works, how do I make it a chrome extension? Thank you so much for your help!

You definitely want to test the code in my original post first to make sure it works for your controller. I think it should based on your controller’s mapping being the same. If it does, here’s what you do to load it as a chrome extension:

  1. Create the extension directory and files:
  • Create a .js file containing your tested code.
  • Create a manifest.json object that contains package info about your code such as the filename that contains it, what websites it should work on, etc.
  • Create chrome extension images in the required resolutions (16, 32, 48, 128 pixels).
  • Place it all in a folder.
    I did the above steps for you, just use the attached zip file :wink: I even used ChatGPT to create a logo image for the extension featuring a game controller with circuitry around it haha
  1. Next, load it locally in your Chrome:
  • Open Chrome and navigate to chrome://extensions/.
  • Enable “Developer mode” in the upper right corner.
  • Click “Load unpacked” and select the directory that you unzipped the above files to.
  • Your extension should now be loaded, and your script will automatically run on Easel project pages, and your controller should work.

EaselGameController.zip (25.0 KB)

Jon - you are awesome! It works great and I have the extension added. I really appreciate you creating that zip file for me. I was reading your description of what to do and once again, it was way over my head. Hopefully I can help in another way at some poin, it won’t be coding though!

2 Likes

Jon,

After I completed a carve, the controller stopped working. I can get it to work again by pasting the script into the console, but the extension doesn’t seem to work. I don’t know if these errors tell you anything, but they are what I see when I have console open in Easel and use the controller:

Woah, thanks for pointing that out, I think I know what I did wrong, I left room for that bug.

Short solution:
After you use your mouse to open or close that popup box / modal, use the X button a couple times to make the popup box disappear and reappear first, and then you can use the interval and direction buttons again.

Long explanation (for anyone who cares):
That error corresponds to line 178 in the code, which is where the code tries to send a “click” event to one of the jog interval buttons.

buttons[index].click();

The error you got is from when trying to click the interval or directional buttons while the “Jog Machine” popup is hidden, which to the program means that those html buttons don’t exist, hence the error “cannot find property ‘click’ of undefined”, because it can’t find the buttons the code is trying to send a pseudo ‘click’ to. I thought I’d fixed it by adding the conditional for it to check that the modal is open:

if (index !== undefined & modalOpen) {
   buttons[index].click();
}

The problem with my code is that it only checks the status of the popup dialog box once (in the beginning) and from then on, it tracks it through your controller X-button presses, and doesn’t know when you open or close it manually with your mouse.

I can see from the left side of your screenshot that the “Jog Machine” dialog box is in fact hidden. My guess is that when the carve was completed and you got to this window:


you closed it manually using your mouse, but my naive code still thinks the popup dialogue box (or modal) is still open. My suggestion would be after you use your mouse to open or close that popup box / modal, use the controller X button a couple times to make the popup box disappear and reappear first, and then you can use the interval and direction buttons again.

When you were re-pasting the code into the console, the line of code that is fixing things is the second line:

let modalOpen = (document.getElementsByClassName("machine-control-modal").length == 1);

This is where it’s getting the state of that modal box again, and then things aren’t out of sync anymore.

I’ll fix this and will reupload the zip file, but in the meantime, use the X button to open / close the popup dialog, and when it’s open, then use the other buttons.

Bug fixed in the attached version. There are more updates to be made, but if you reload this extension, that error shouldn’t happen again (at least not for the scenario outlined above).
EaselGameController_V1.3.zip (25.2 KB)

In this version, I explicitly check the state of the popup dialogue box before it attempts to click on the interval or directional buttons, and it alerts you if you’re trying to do something while the dialogue box is closed.

1 Like

Thanks Jon, I will give it a try and let you know if I have any issues.