Web USB device detection and permission

Web USB offers a a great way to bridge the gap between hardware-based systems and the modern web ecosystem. Whether it's legacy hardware that is being brought into the connected world, or home brew hardware for solving a problem, this article is the first step in building a reliable interface utilizing Web USB.

The first step (and arguably the hardest part) is determining the hardware identifiers for the target hardware. This is combination of a Vendor ID (specific to a vendor) and product ID (specific to the hardware). These can be found by connecting a device to a computer and searching in the device configuration manager for the hardware properties. These values are often referenced in hex form, but are not always denoted as such, so be careful to keep the values referenced in hex to avoid confusion.

For this example, we are targeting an STM32 Virtual Com device, which is a serial communication over USB device used by many low-level hardware devices. The device has Vendor ID 0483 and Product ID 5740. It is important to note that Web USB utilizes libusb drivers, so it might be required to override the default drivers for either libusb or WinUSB drivers using a tool like Zadig. We will also outline the process of building a driver override installer in another blog post.

For this example, we are establishing a TypeScript class which will handle device detection and permission and will retain a callback for channeling device communication data the implementing class.

import DeviceController from './deviceController'

const RECEIVER_VENDOR_ID = 0x0483
const RECEIVER_PRODUCT_ID = 0x5740
const DEVICE_NAME = 'STM32 Device'

export default class USBDevice {
  callback: ((data: ComData) => void)
  targetDevice: DeviceController | null
}

In the constructor for the class, we will assign the callback to a local reference and setup event listeners to detect when devices are attached and detached. This will also begin the process of checking for an already connected device, but this method will fail until the permission to access the target hardware has been granted.

  constructor (callback: ((data: ComData) => void)) {
    this.callback = callback
    this.targetDevice = null

    navigator.usb.addEventListener('connect', event => { this.attached(event) })
    navigator.usb.addEventListener('disconnect', event => { this.detached(event) })
    
    this.checkDevices()
  }

  async checkDevices () {
    let devices = await navigator.usb.getDevices()
    devices.forEach(device => {
      if (this.matchesTarget(device)) {
        console.log(DEVICE_NAME + ': Already Connected')
        this.connect(device)
      }
    })
  }

  private attached (event: WebUSB.ConnectionEvent) {
    if (this.matchesTarget(event.device)) {
      console.log(DEVICE_NAME + ': Connected')
      this.connect(event.device)
    }
  }

  private detached (event: WebUSB.ConnectionEvent) {
    if (this.matchesTarget(event.device) && this.targetDevice && this.targetDevice.isSameDevice(event.device)) {
      console.log(DEVICE_NAME + ': Disconnected')
      this.close()
    }
  }

While the constructor initializes the class, it does not yet have the ability to see any hardware devices as the permissions have not yet been granted. Permissions are granted through a permission prompt which must be triggered by a user action (ie: clicking a button). Once granted, a permission will be remembered and automatically granted for future requests. In the example code, the first call to checkDevices() will find no devices, but a user action that triggers the requestPermission() will, assuming the user approves the permission, result in the device being detected.

Once a device is detected via the checkDevices() method, or is detected by the attach event, the device reference can be used to open the connection and perform actions on the device.

Here is the full example code:

import DeviceController from './deviceController'

const RECEIVER_VENDOR_ID = 0x0483
const RECEIVER_PRODUCT_ID = 0x5740
const DEVICE_NAME = 'STM32 Device'

export default class USBDevice {
  callback: ((data: ComData) => void)
  targetDevice: DeviceController | null

  constructor (callback: ((data: ComData) => void)) {
    this.callback = callback
    this.targetDevice = null

    navigator.usb.addEventListener('connect', event => { this.attached(event) })
    navigator.usb.addEventListener('disconnect', event => { this.detached(event) })

    this.checkDevices()
  }

  async checkDevices () {
    let devices = await navigator.usb.getDevices()
    devices.forEach(device => {
      if (this.matchesTarget(device)) {
        console.log(DEVICE_NAME + ': Already Connected')
        this.connect(device)
      }
    })
  }

  async requestPermission () {
    if (!this.targetDevice) {
      try {
        let device = await navigator.usb.requestDevice({
          filters: [{
            vendorId: RECEIVER_VENDOR_ID,
            productId: RECEIVER_PRODUCT_ID
          }]
        })
        this.connect(device)
      } catch (error) {
        console.log(DEVICE_NAME + ': Permission Denied')
      }
    }
  }

  close () {
    if (this.targetDevice) {
      this.targetDevice.disconnect()
      this.targetDevice = null
    }
  }

  private attached (event: WebUSB.ConnectionEvent) {
    if (this.matchesTarget(event.device)) {
      console.log(DEVICE_NAME + ': Connected')
      this.connect(event.device)
    }
  }

  private detached (event: WebUSB.ConnectionEvent) {
    if (this.matchesTarget(event.device) && this.targetDevice && this.targetDevice.isSameDevice(event.device)) {
      console.log(DEVICE_NAME + ': Disconnected')
      this.close()
    }
  }

  private connect (device: WebUSB.Device) {
    this.targetDevice = new DeviceController(device, this.callback)
  }

  private matchesTarget (device: WebUSB.Device) {
    return device.vendorId === RECEIVER_VENDOR_ID &&
      device.productId === RECEIVER_PRODUCT_ID
  }
}

In the next blog post, we will go into depth on the DeviceController class and how to handle the communication with the USB device.