Skip to content

Commit

Permalink
Multiple Devices
Browse files Browse the repository at this point in the history
- Added ability to connect multiple devices at once. They can act separately.
- Devices can be distinguished between by their id, which is the MD5 hash of either the serial number (if it exists), or the device path. This allows the id to be the same all the time, or if the path is used, whenever it is plugged into the same USB port.
- Incorporated change by @peternewman for standardising the pid field in the device definitions (instead of did).
  • Loading branch information
hopejr committed May 1, 2023
1 parent bcdbcca commit 8b88508
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 83 deletions.
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# ShuttleControlUSB
# ShuttleControlUSB [![npm](https://img.shields.io/npm/v/shuttle-control-usb.svg)](https://www.npmjs.com/package/shuttle-control-usb)

_A Library to use Contour Design ShuttleXpress and ShuttlePro (v1 and v2) in Node.js projects without the driver._
A Library to use Contour Design ShuttleXpress and ShuttlePro (v1 and v2) in Node.js projects without the driver. In some markets, these devices are also known as Multimedia Controller Xpress and Multimedia Controller PRO v2.

_This library now supports multiple devices connected at one time (since v1.1.0). This change is backwards compatible and simply includes the device connection UUID for each event as the final parameter in the callback._

## Installation
```sh
npm install --save https://github.com/hopejr/ShuttleControlUSB.git
npm install shuttle-control-usb
```

## Usage
Expand All @@ -24,7 +26,7 @@ shuttle.start();
### Methods
`start()`

Starts the service and monitors USB device connections for ShuttleXPress or ShuttlePro (v1 and v2). It will find the first device connected. Only one device is supported at a time.
Starts the service and monitors USB device connections for ShuttleXPress or ShuttlePro (v1 and v2). It will find all connected devices.

This should be called after the 'connect' event listener has been declared, otherwise already-connected devices will not be detected.

Expand All @@ -41,6 +43,7 @@ Emitted when a device has been plugged into a USB port.

Returns:
- `deviceInfo` Object
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path, used to distinguish between multiple devices that may be connected at once.
- `name` String - name of the device ('ShuttleXpress', 'ShuttlePro v1', or 'ShuttlePro v2')
- `hasShuttle` Boolean
- `hasJog` Boolean
Expand All @@ -49,42 +52,51 @@ Returns:
#### Event: `disconnected`
Emitted when the device has been unplugged or has failed.

Returns:
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path.

#### Event: `shuttle`
Emitted when shuttle data is available from the device.

Returns:
- `value` Integer - Range from -7 to 7 for ShuttleXpress and ShuttlePro (v1 and v2)
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path.

#### Event: `shuttle-trans`
Emitted when shuttle data is available from the device.

Returns:
- `old` Integer
- `new` Integer
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path.

#### Event: `jog`
Emitted when jog data is available from the device.

Returns:
- `value` Integer - Range from 0 to 255 for ShuttleXpress and ShuttlePro (v1 and v2)
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path.

#### Event: `jog-dir`
Emitted when jog data is available from the device.

Returns:
- `dir` Integer - Either 1 (clockwise) or -1 (counter-clockwise)
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path.

#### Event: `buttondown`
Emitted when a button is pressed on the device.

Returns:
- `button` Integer - the button number
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path.

#### Event: `buttonup`
Emitted when a button is released on the device.

Returns:
- `button` Integer - the button number
- `id` String - either an MD5 hash of the serial number (if it exists) or the device path.


## Linux Note
Expand All @@ -95,10 +107,6 @@ By default, the udev system adds ShuttleXpress, ShuttlePro V1, and ShuttlePro V2
Then reboot your computer.


# Future Features
I'm looking at allowing multiple devices to be connected at once. Stay tuned for this feature. It will likely result in a change to the API and will be a breaking change.


## Licence
MIT

154 changes: 87 additions & 67 deletions lib/Shuttle.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const hid = require('node-hid')
const { usb } = require('usb')
const crypto = require('crypto')
const shuttleDevices = require('./ShuttleDefs')
const EventEmitter = require('events').EventEmitter

Expand All @@ -14,101 +15,120 @@ const defaultState = {
class Shuttle extends EventEmitter {
constructor () {
super()
this._connected = false
this._hid = null;
this._state = JSON.parse(JSON.stringify(defaultState))
this._hid = [];
}

start () {
if (!this._connected) {
usb.on('attach', (d) => {
// Delay connection by 1 second because
// it takes a second to load on macOS
setTimeout(() => {
this._connect()
}, process.platform === 'darwin' ? 1000 : 0)
})
// Find already
this._connect()
}
usb.on('attach', (d) => {
// Delay connection by 1 second because
// it takes a second to load on macOS
setTimeout(() => {
this._connect()
}, process.platform === 'darwin' ? 1000 : 0)
})
// Find already
this._connect()
}

stop () {
usb.unrefHotplugEvents()
if (this._connected) {
if (this._hid !== null) {
this._hid.close()
this._hid = null
}
this._connected = false
if (this._hid.length > 0) {
this._hid.forEach((device) => {
device.hid.close()
})
}
}

getDeviceList () {
return this._hid.map((device) => {
return {
id: device.id,
name: device.def.name,
hasShuttle: device.def.rules.shuttle !== undefined,
hasJog: device.def.rules.jog !== undefined,
numButtons: device.def.buttonMasks.length
}
})
}

_connect () {
if (this._hid === null) {
let foundDevice = null
shuttleDevices.forEach((device) => {
if (foundDevice === null) {
try {
this._hid = new hid.HID(device.vid, device.did)
foundDevice = device
} catch (err) {
// Ignore
}
const foundDevices = []
const devices = hid.devices()
shuttleDevices.forEach((deviceDef) => {
const connectedPaths = this._hid.map(h => h.path)
const filteredDevices = devices.filter((d) => {
return d.vendorId === deviceDef.vid && d.productId === deviceDef.pid
&& !connectedPaths.includes(d.path)
})
filteredDevices.forEach((device) => {
try {
const newHid = new hid.HID(device.path)
const newId = crypto.createHash('md5').update(device.serialNumber || device.path).digest('hex')
this._hid.push({
id: newId,
hid: newHid,
def: deviceDef,
path: device.path,
state: JSON.parse(JSON.stringify(defaultState))
})
foundDevices.push(newId)
} catch (err) {
// Ignore
}
})
})

if (this._hid !== null) {
this._connected = true

foundDevices.forEach((foundDevice) => {
const deviceIdx = this._hid.findIndex(ele => ele.id === foundDevice)
if (deviceIdx > -1) {
const device = this._hid[deviceIdx]
this.emit('connected', {
name: foundDevice.name,
hasShuttle: foundDevice.rules.shuttle !== undefined,
hasJog: foundDevice.rules.jog !== undefined,
numButtons: foundDevice.buttonMasks.length
id: device.id,
name: device.def.name,
hasShuttle: device.def.rules.shuttle !== undefined,
hasJog: device.def.rules.jog !== undefined,
numButtons: device.def.buttonMasks.length
})
foundDevice.buttonMasks.forEach((ele) => {
this._state.buttons.push(false)
device.def.buttonMasks.forEach((ele) => {
device.state.buttons.push(false)
})
this._hid.on('data', (data) => {
this._updateData(data, foundDevice)
device.hid.on('data', (data) => {
this._updateData(data, device)
})
this._hid.on('error', (error) => {
this._hid.close()
this._hid = null
this.emit('disconnected')
this._state = JSON.parse(JSON.stringify(defaultState))
this._connected = false
device.hid.on('error', (error) => {
device.hid.close()
this._hid.splice(deviceIdx, 1)
this.emit('disconnected', device.id)
})
}
}
})
}

_updateData (data, device) {
if (data.length === device.packetSize) {
let shuttle = this._read(data, device.rules.shuttle.offset, device.rules.shuttle.type)
let jog = this._read(data, device.rules.jog.offset, device.rules.jog.type)
let buttonsRaw = this._read(data, device.rules.buttons.offset, device.rules.buttons.type)
if (shuttle !== this._state.shuttle) {
this.emit('shuttle', shuttle)
this.emit('shuttle-trans', this._state.shuttle, shuttle)
this._state.shuttle = shuttle
if (data.length === device.def.packetSize) {
let shuttle = this._read(data, device.def.rules.shuttle.offset, device.def.rules.shuttle.type)
let jog = this._read(data, device.def.rules.jog.offset, device.def.rules.jog.type)
let buttonsRaw = this._read(data, device.def.rules.buttons.offset, device.def.rules.buttons.type)
if (shuttle !== device.state.shuttle) {
this.emit('shuttle', shuttle, device.id)
this.emit('shuttle-trans', device.state.shuttle, shuttle, device.id)
device.state.shuttle = shuttle
}
if (jog !== this._state.jog) {
let dir = (this._state.jog === 0xff && jog === 0) || (!(this._state.jog === 0 && jog === 0xff) && this._state.jog < jog) ? 1 : -1
this._state.jog = jog
this.emit('jog', jog)
this.emit('jog-dir', dir)
if (jog !== device.state.jog) {
let dir = (device.state.jog === 0xff && jog === 0) || (!(device.state.jog === 0 && jog === 0xff) && device.state.jog < jog) ? 1 : -1
device.state.jog = jog
this.emit('jog', jog, device.id)
this.emit('jog-dir', dir, device.id)
}
// Treat buttons a little differently. Need to do button up and button down events
device.buttonMasks.forEach((mask, index) => {
device.def.buttonMasks.forEach((mask, index) => {
const button = (buttonsRaw & mask)
if (button && !this._state.buttons[index]) {
this.emit('buttondown', index + 1)
} else if (!button && this._state.buttons[index]) {
this.emit('buttonup', index + 1)
if (button && !device.state.buttons[index]) {
this.emit('buttondown', index + 1, device.id)
} else if (!button && device.state.buttons[index]) {
this.emit('buttonup', index + 1, device.id)
}
this._state.buttons[index] = button
device.state.buttons[index] = button
})
}
}
Expand Down
6 changes: 3 additions & 3 deletions lib/ShuttleDefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = [
name: 'ShuttleXpress',
vendor: 'Contour Design, Inc.',
vid: 0x0b33,
did: 0x0020,
pid: 0x0020,
packetSize: 5,
rules: {
shuttle: {
Expand All @@ -27,7 +27,7 @@ module.exports = [
name: 'ShuttlePro v1',
vendor: 'Contour Design, Inc.',
vid: 0x0b33,
did: 0x0010,
pid: 0x0010,
packetSize: 5,
rules: {
shuttle: {
Expand All @@ -49,7 +49,7 @@ module.exports = [
name: 'ShuttlePro v2',
vendor: 'Contour Design, Inc.',
vid: 0x0b33,
did: 0x0030,
pid: 0x0030,
packetSize: 5,
rules: {
shuttle: {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shuttle-control-usb",
"version": "1.0.6",
"version": "1.1.0",
"description": "NodeJS Interface for Contour ShuttleXpress, ShuttlePro V1, and ShuttlePro V2",
"main": "index.js",
"scripts": {
Expand Down
19 changes: 17 additions & 2 deletions tests/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const shuttle = require('../lib/Shuttle')

shuttle.on('connected', (deviceInfo) => {
console.log('Starting tests')
console.log('Connected', deviceInfo.id, deviceInfo.name)
test('shuttle test', (t) => {
t.plan(3)

Expand All @@ -15,10 +16,24 @@ shuttle.on('connected', (deviceInfo) => {
} else if (deviceInfo.name === 'ShuttlePro v2') {
t.equal(deviceInfo.numButtons === 15)
}
shuttle.stop()
console.log('Unplug device')
})
})

shuttle.on('disconnected', (id) => {
console.log('Disconnected', id)
if (shuttle.getDeviceList().length === 0) {
console.log('Testing complete')
shuttle.stop()
}
})

shuttle.on('buttonup', (button, id) => {
console.log('Shuttle button up', button, id)
})

shuttle.start()

console.log('Plug device in')
if (shuttle.getDeviceList().length === 0) {
console.log('Plug device in')
}

0 comments on commit 8b88508

Please sign in to comment.