Reviving legacy hardware with WebHID

denladeside

Lars Knudsen 🇩🇰

Posted on June 9, 2021

Reviving legacy hardware with WebHID

Finding gold!

For many years, I've been saving different odd hardware devices, that I would find use for SomeDay(TM).

In reality, that day rarely comes, so a few months back, I decided to get rid of all the unused stuff.

One item I initially put in the 'out' pile was a 3Dconnexion Spaceball 5000, because surely there would be little to no support for it in any OS.

However, I tried to connect it to my Ubuntu machine to see what would happen.

3DConnection Spaceball 5000

Running lsusb gives us the following:

ID 046d:c621 Logitech, Inc. 3Dconnexion Spaceball 5000
Enter fullscreen mode Exit fullscreen mode

... adding the -v switch, tells us that there is one exposed HID interface:

...
        bInterfaceClass         3 Human Interface Device
...
Enter fullscreen mode Exit fullscreen mode

NOTE: Using (Ubuntu) Linux, in order to get access from user space, add the following, using the USB Vendor ID returned from lsusb to a udev rules file (e.g. /etc/udev/rules/50-webhid.rules):

SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", MODE:="0666", GROUP="input"
Enter fullscreen mode Exit fullscreen mode

And run sudo udevadm control --reload-rules && sudo udevadm trigger

On other systems, this is not necessary.

WebHID

Looking at the Fugu API tracker, we see that WebHID was released in Chrome M89, so let's try to hook it up and see what kind of data the Spaceball sends out.

We will start with something very simple, just to read all the data coming from the device.

<button id="scan">SCAN</button>
<script>
function scan() {
    navigator.hid.requestDevice({filters: [{ vendorId: 0x046d }]}).then(devices => {
        if (!devices.length) return;

        const device = devices[0];

        device.open().then(() => {
            console.log('Opened device: ' + device.productName);
            device.addEventListener('inputreport', e => {
                console.log('Report ID', e.reportId);
                console.log('Data', new Int8Array(e.data.buffer));
            });
        });
    });
}

document.querySelector('#scan').addEventListener('click', scan);
</script>
Enter fullscreen mode Exit fullscreen mode

Running this in Chrome and clicking the SCAN button with the device attached should show a dialog, where we can now select the Spaceball 5000:

WebHID device selector

After a successful connection, you should see the following in the console:

Opened device: 3Dconnexion SpaceBall 5000 USB

YAY!

All the data!

Manipulating the Spaceball sends a lot of data packets of 6 bytes to the console, which seems to be pairs with report ID 1 and 2:

Report ID 1
Data Int8Array(6) [101, -1, -2, -1, 95, 0]
Report ID 2
Data Int8Array(6) [-27, 0, 35, 1, -126, -1]
Enter fullscreen mode Exit fullscreen mode

After searching a bit for a protocol description, I read a small post from 2006, that the values are 16-bit signed X, Y, Z translation values, followed by the same for rotation.

Let's make a small change to the code:

...
device.addEventListener('inputreport', e => {
    // First attempt: Print all the data
    // console.log('Report ID', e.reportId);
    // console.log('Data', new Int8Array(e.data.buffer));

    // Second attempt: extract translation and rotation:
    if (e.reportId === 1) console.log('T', new Int16Array(e.data.buffer));
    if (e.reportId === 2) console.log('R', new Int16Array(e.data.buffer));
});
...
Enter fullscreen mode Exit fullscreen mode

Now the data starts to make a bit more sense:

T Int16Array(3) [33, -18, 132]
R Int16Array(3) [-230, 67, 0]
T Int16Array(3) [39, -15, 124]
R Int16Array(3) [-208, 67, 0]
Enter fullscreen mode Exit fullscreen mode

and when releasing the Spaceball, it seems to go to zero:

T Int16Array(3) [0, 0, 0]
R Int16Array(3) [0, 0, 0]
Enter fullscreen mode Exit fullscreen mode

Making a simple driver

Now that all the basics are in place, let's write a small web driver for the device.

Extending EventTarget gives the driver the ability to send the parsed packets on to any listener as proper events.

export const SpaceDriver = new class extends EventTarget {
    #device // Just allow one device, for now

    constructor() {
        super();

        this.handleInputReport = this.handleInputReport.bind(this);

        // See if a paired device is already connected
        navigator.hid.getDevices().then((devices) => {
            devices.filter(d => d.vendorId === deviceFilter.vendorId).forEach(this.openDevice.bind(this));
        });

        navigator.hid.addEventListener('disconnect', evt => {
            const device = evt.device;
            console.log('disconnected', device);
            if (device === this.#device) {
                this.disconnect();
            }
        });

    }

    openDevice(device) {
        this.disconnect(); // If another device is connected - close it

        device.open().then(() => {
            console.log('Opened device: ' + device.productName);
            device.addEventListener('inputreport', this.handleInputReport);
            this.#device = device;
            this.dispatchEvent(new CustomEvent('connect', {detail: { device }}));
        });
    }

    disconnect() {
        this.#device?.close();
        this.#device = undefined;
        this.dispatchEvent(new Event('disconnect'));
    }

    scan() {
        navigator.hid.requestDevice(requestParams).then(devices => {
            if (devices.length == 0) return;
            this.openDevice(devices[0]);
        });
    }

    handleInputReport(e) {
        switch(e.reportId) {
            case 1: // x, y, z
            this.handleTranslation(new Int16Array(e.data.buffer));
            break;
            case 2: // yaw, pitch, roll
            this.handleRotation(new Int16Array(e.data.buffer));
            break;
        }
    }

    handleTranslation(val) {
        this.dispatchEvent(new CustomEvent('translate', {
            detail: {
                x: val[0],
                y: val[1],
                z: val[2]
            }
        }));
    }

    handleRotation(val) {
        this.dispatchEvent(new CustomEvent('rotate', {
            detail: {
                rx: -val[0],
                ry: -val[1],
                rz: val[2]
            }
        }));
    }
}

Enter fullscreen mode Exit fullscreen mode

Visualizing with Web Components and CSS3D

Just writing numbers in the console is no fun, and as we are using a modern browser with native CSS3D and Web Component support, why not make a silly 3D object in a component, we can manipulate with the Spaceball:

export class Demo3DObj extends HTMLElement {
    #objtranslate
    #objrotate
    #obj

    constructor() {
        super();
        this.#objtranslate = '';
        this.#objrotate = '';
    }

    connectedCallback() {
        this.innerHTML = `
        <style>
        .scene {
            width: 200px;
            height: 200px;
            margin: 200px;
            perspective: 500px;
        }

        .obj {
            width: 200px;
            height: 200px;
            position: relative;
            transform-style: preserve-3d;
            transform: translateZ(-1000px);
            transition: transform 100ms;
        }

        .plane {
            position: absolute;
            width: 200px;
            height: 200px;
            border: 5px solid black;
            border-radius: 50%;
        }

        .red {
            background: rgba(255,0,0,0.5);
            transform: rotateY(-90deg)
        }

        .green {
            background: rgba(0,255,0,0.5);
            transform: rotateX( 90deg)
        }

        .blue {
            background: rgba(0,0,255,0.5);
        }
        </style>

        <div class="scene">
            <div class="obj">
                <div class="plane red"></div>
                <div class="plane green"></div>
                <div class="plane blue"></div>
            </div>
        </div>
        `;

        this.#obj = this.querySelector('.obj');
    }

    setTranslation(x, y, z) {
        this.#objtranslate = `translateX(${x}px) translateY(${y}px) translateZ(${z}px) `;
        this.#obj.style.transform = this.#objtranslate + this.#objrotate;
    }

    setRotation(rx, ry, rz) {
        this.#objrotate = `rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${rz}deg) `;
        this.#obj.style.transform = this.#objtranslate + this.#objrotate;
    }
}
customElements.define('demo-3dobj', Demo3DObj);
Enter fullscreen mode Exit fullscreen mode

All that is left is to combine the SpaceDriver and demo-3dobj to see the direct manipulation of a 3D object using the WebHID connected Spaceball 5000:

WebHID 3D Object Manipulation

Live demo and source code on GitHub

Try it out here!

source

ENJOY!

💖 💪 🙅 🚩
denladeside
Lars Knudsen 🇩🇰

Posted on June 9, 2021

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

Sign up to receive the latest update from our blog.

Related