From 1d7bb94c0423423dd5c317f2421673ff0bbfd26f Mon Sep 17 00:00:00 2001 From: Liam McLoughlin Date: Tue, 2 Aug 2022 10:53:05 +0100 Subject: [PATCH] Add USB device host support (#199) Signed-off-by: Liam McLoughlin --- packages/fdb-debugger/src/index.ts | 1 + packages/sdk-cli/package.json | 2 + .../sdk-cli/src/api/developerRelay.test.ts | 57 ++-- packages/sdk-cli/src/api/developerRelay.ts | 21 +- packages/sdk-cli/src/cli.ts | 2 +- packages/sdk-cli/src/commands/connect.test.ts | 97 +++---- packages/sdk-cli/src/commands/connect.ts | 50 ++-- packages/sdk-cli/src/commands/hosts.ts | 31 ++- .../src/models/HostConnections.test.ts | 175 ++++++++---- .../sdk-cli/src/models/HostConnections.ts | 85 +++++- packages/sdk-cli/src/models/HostTypes.ts | 2 + .../sdk-cli/src/models/PULSEAdapter.test.ts | 198 ++++++++++++++ packages/sdk-cli/src/models/PULSEAdapter.ts | 101 +++++++ .../sdk-cli/src/models/USBDebugHost.test.ts | 67 +++++ packages/sdk-cli/src/models/USBDebugHost.ts | 15 ++ .../src/models/USBSerialDevice.test.ts | 250 ++++++++++++++++++ .../sdk-cli/src/models/USBSerialDevice.ts | 172 ++++++++++++ packages/sdk-cli/src/models/__mocks__/usb.ts | 0 yarn.lock | 36 ++- 19 files changed, 1154 insertions(+), 208 deletions(-) create mode 100644 packages/sdk-cli/src/models/HostTypes.ts create mode 100644 packages/sdk-cli/src/models/PULSEAdapter.test.ts create mode 100644 packages/sdk-cli/src/models/PULSEAdapter.ts create mode 100644 packages/sdk-cli/src/models/USBDebugHost.test.ts create mode 100644 packages/sdk-cli/src/models/USBDebugHost.ts create mode 100644 packages/sdk-cli/src/models/USBSerialDevice.test.ts create mode 100644 packages/sdk-cli/src/models/USBSerialDevice.ts create mode 100644 packages/sdk-cli/src/models/__mocks__/usb.ts diff --git a/packages/fdb-debugger/src/index.ts b/packages/fdb-debugger/src/index.ts index 54555eea..5e4fba8f 100644 --- a/packages/fdb-debugger/src/index.ts +++ b/packages/fdb-debugger/src/index.ts @@ -303,6 +303,7 @@ export class RemoteHost extends EventEmitter { 'app.launchComponent', FDBTypes.LaunchComponentParams, FDBTypes.AppComponent, + { minTimeout: 15000 }, ); private changeSerialization = (serialization: FDBTypes.SerializationType) => { diff --git a/packages/sdk-cli/package.json b/packages/sdk-cli/package.json index 2791dc19..796bb5a7 100644 --- a/packages/sdk-cli/package.json +++ b/packages/sdk-cli/package.json @@ -20,6 +20,7 @@ "@fitbit/fdb-protocol": "^1.8.0-pre.0", "@fitbit/jsonrpc-ts": "^3.2.1", "@fitbit/portable-pixmap": "^1.0.3", + "@fitbit/pulse": "^0.1.0", "@moleculer/vorpal": "^1.11.5", "@openid/appauth": "^1.2.8", "chalk": "^4.1.0", @@ -42,6 +43,7 @@ "tslib": "^2.0.1", "untildify": "^4.0.0", "update-notifier": "^4.1.1", + "usb": "^2.4.3", "websocket-stream": "^5.5.2" }, "devDependencies": { diff --git a/packages/sdk-cli/src/api/developerRelay.test.ts b/packages/sdk-cli/src/api/developerRelay.test.ts index 72531dc6..563a8f84 100644 --- a/packages/sdk-cli/src/api/developerRelay.test.ts +++ b/packages/sdk-cli/src/api/developerRelay.test.ts @@ -10,8 +10,6 @@ import mockWithPromiseWaiter from '../testUtils/mockWithPromiseWaiter'; jest.mock('websocket-stream', () => jest.fn()); jest.mock('../auth'); -const mockHostID = 'fakeHost'; - const mockAppHost = { id: 'apphostA', displayName: 'App Host', @@ -28,6 +26,15 @@ const mockCompanionHost = { let endpointMock: nock.Scope; +function relayHostToHost(relayHost: typeof mockAppHost) { + return { + displayName: relayHost.displayName, + roles: relayHost.roles, + available: relayHost.state === 'available', + connect: expect.any(Function), + }; +} + function mockHostsResponse(status: number, payload?: any) { endpointMock .get('/1/user/-/developer-relay/hosts.json') @@ -45,48 +52,22 @@ function mockConnectionURLResponse(hostID: string, response: string) { } beforeEach(() => { - (auth.getAccessToken as jest.Mock).mockResolvedValueOnce('mockToken'); + (auth.getAccessToken as jest.Mock).mockResolvedValue('mockToken'); endpointMock = nock(environment().config.apiUrl); }); describe('hosts()', () => { - it('returns list of connected app hosts', async () => { - mockHostsSuccessResponse(); - return expect(developerRelay.hosts()).resolves.toEqual( - expect.objectContaining({ - appHost: [mockAppHost], - }), - ); - }); - - it('returns list of connected companion hosts', async () => { + it('returns list of connected hosts', async () => { mockHostsSuccessResponse(); - return expect(developerRelay.hosts()).resolves.toEqual( - expect.objectContaining({ - companionHost: [mockCompanionHost], - }), - ); + return expect(developerRelay.hosts()).resolves.toEqual([ + relayHostToHost(mockAppHost), + relayHostToHost(mockCompanionHost), + ]); }); it('returns empty lists if no hosts are connected', async () => { mockHostsSuccessResponse([]); - return expect(developerRelay.hosts()).resolves.toEqual({ - appHost: [], - companionHost: [], - }); - }); - - it('ignores hosts with unknown roles', async () => { - mockHostsSuccessResponse([ - { - ...mockAppHost, - roles: ['__bad_role__'], - }, - ]); - return expect(developerRelay.hosts()).resolves.toEqual({ - appHost: [], - companionHost: [], - }); + return expect(developerRelay.hosts()).resolves.toEqual([]); }); it('parses a 403 response for error reasons', async () => { @@ -122,12 +103,14 @@ describe('connect()', () => { beforeEach(async () => { mockWebSocket = new stream.Duplex(); + mockHostsSuccessResponse([mockAppHost]); socketPromise = mockWithPromiseWaiter( websocketStream as any, mockWebSocket, ); - mockConnectionURLResponse(mockHostID, `${mockConnectionURL}\r\n`); - connectPromise = developerRelay.connect(mockHostID); + const host = (await developerRelay.hosts())[0]; + mockConnectionURLResponse(mockAppHost.id, `${mockConnectionURL}\r\n`); + connectPromise = host.connect(); await socketPromise; }); diff --git a/packages/sdk-cli/src/api/developerRelay.ts b/packages/sdk-cli/src/api/developerRelay.ts index 97486985..b9d51123 100644 --- a/packages/sdk-cli/src/api/developerRelay.ts +++ b/packages/sdk-cli/src/api/developerRelay.ts @@ -6,7 +6,7 @@ import { apiFetch, assertAPIResponseOK, decodeJSON } from './baseAPI'; import { assertContentType } from '../util/fetchUtil'; // tslint:disable-next-line:variable-name -export const Host = t.type( +export const RelayHost = t.type( { id: t.string, displayName: t.string, @@ -15,12 +15,12 @@ export const Host = t.type( }, 'Host', ); -export type Host = t.TypeOf; +export type RelayHost = t.TypeOf; // tslint:disable-next-line:variable-name const HostsResponse = t.type( { - hosts: t.array(Host), + hosts: t.array(RelayHost), }, 'HostsResponse', ); @@ -45,7 +45,7 @@ function createWebSocket(uri: string) { }); } -export async function connect(hostID: string) { +async function connect(hostID: string) { const url = await getConnectionURL(hostID); return createWebSocket(url); } @@ -55,11 +55,10 @@ export async function hosts() { decodeJSON(HostsResponse), ); - const hostsWithRole = (role: string) => - response.hosts.filter((host) => host.roles.includes(role)); - - return { - appHost: hostsWithRole('APP_HOST'), - companionHost: hostsWithRole('COMPANION_HOST'), - }; + return response.hosts.map((host) => ({ + available: host.state === 'available', + connect: () => connect(host.id), + displayName: host.displayName, + roles: host.roles, + })); } diff --git a/packages/sdk-cli/src/cli.ts b/packages/sdk-cli/src/cli.ts index 3a688144..e2725bf9 100644 --- a/packages/sdk-cli/src/cli.ts +++ b/packages/sdk-cli/src/cli.ts @@ -44,7 +44,7 @@ cli.use(logout); cli.use(repl({ hostConnections })); if (enableQACommands) { - cli.use(hosts); + cli.use(hosts({ hostConnections })); cli.use(mockHost); } diff --git a/packages/sdk-cli/src/commands/connect.test.ts b/packages/sdk-cli/src/commands/connect.test.ts index 2904426f..e4c61e2c 100644 --- a/packages/sdk-cli/src/commands/connect.test.ts +++ b/packages/sdk-cli/src/commands/connect.test.ts @@ -2,49 +2,49 @@ import events from 'events'; import vorpal from '@moleculer/vorpal'; -import connect, { DeviceType } from './connect'; +import connect from './connect'; import commandTestHarness from '../testUtils/commandTestHarness'; -import * as developerRelay from '../api/developerRelay'; -import HostConnections, { HostType } from '../models/HostConnections'; +import HostConnections, { Host } from '../models/HostConnections'; +import { DeviceType, HostType } from '../models/HostTypes'; jest.mock('../models/HostConnections'); -const mockAppHost: developerRelay.Host = { - id: 'apphost', +const mockAppHost: Host = { + connect: jest.fn(), displayName: 'App Host', roles: ['APP_HOST'], - state: 'available', + available: true, }; -const mockAppHost2: developerRelay.Host = { - id: 'apphost2', +const mockAppHost2: Host = { + connect: jest.fn(), displayName: 'Another App Host', roles: ['APP_HOST'], - state: 'available', + available: true, }; -const mockCompanionHost: developerRelay.Host = { - id: 'companionhost', +const mockCompanionHost: Host = { + connect: jest.fn(), displayName: 'Companion Host', roles: ['COMPANION_HOST'], - state: 'available', + available: true, }; -const mockCompanionHost2: developerRelay.Host = { - id: 'companionhost2', +const mockCompanionHost2: Host = { + connect: jest.fn(), displayName: 'Another Companion Host', roles: ['COMPANION_HOST'], - state: 'available', + available: true, }; -const mockBusyAppHost: developerRelay.Host = { - id: 'apphost3', +const mockBusyAppHost: Host = { + connect: jest.fn(), displayName: 'Yet Another App Host', roles: ['APP_HOST'], - state: 'busy', + available: false, }; -const mockRelayHosts = { +const mockRelayHosts: { [key in DeviceType]: Host[] } = { device: [mockAppHost, mockAppHost2], phone: [mockCompanionHost, mockCompanionHost2], }; @@ -52,28 +52,21 @@ const mockRelayHosts = { let cli: vorpal; let mockLog: jest.Mock; let mockPrompt: jest.Mock; -let mockWS: events.EventEmitter; +let mockStream: events.EventEmitter; let hostConnections: HostConnections; -let relayHostsSpy: jest.SpyInstance; +let listOfTypeSpy: jest.SpyInstance; let hostConnectSpy: jest.SpyInstance; -const mockRelayHostsResponse = { - device: (hosts: developerRelay.Host[]) => - relayHostsSpy.mockResolvedValueOnce({ appHost: hosts, companionHost: [] }), - phone: (hosts: developerRelay.Host[]) => - relayHostsSpy.mockResolvedValueOnce({ appHost: [], companionHost: hosts }), -}; - beforeEach(() => { hostConnections = new HostConnections(); ({ cli, mockLog, mockPrompt } = commandTestHarness( connect({ hostConnections }), )); - relayHostsSpy = jest.spyOn(developerRelay, 'hosts'); + listOfTypeSpy = jest.spyOn(hostConnections, 'listOfType'); hostConnectSpy = jest.spyOn(hostConnections, 'connect'); - mockWS = new events.EventEmitter(); - hostConnectSpy.mockResolvedValueOnce({ ws: mockWS }); + mockStream = new events.EventEmitter(); + hostConnectSpy.mockResolvedValueOnce({ stream: mockStream }); }); function doConnect(type: DeviceType) { @@ -85,17 +78,17 @@ describe.each<[DeviceType, HostType]>([ ['phone', 'companionHost'], ])('when the device type argument is %s', (deviceType, hostType) => { it(`logs an error if no ${deviceType}s are connected`, async () => { - mockRelayHostsResponse[deviceType]([]); + listOfTypeSpy.mockResolvedValueOnce([]); await doConnect(deviceType); expect(mockLog.mock.calls[0]).toMatchSnapshot(); }); describe(`when a single ${deviceType} is connected`, () => { - let mockHost: developerRelay.Host; + let mockHost: Host; beforeEach(() => { mockHost = mockRelayHosts[deviceType][0]; - mockRelayHostsResponse[deviceType]([mockHost]); + listOfTypeSpy.mockResolvedValueOnce([mockHost]); return doConnect(deviceType); }); @@ -107,28 +100,25 @@ describe.each<[DeviceType, HostType]>([ expect(mockLog.mock.calls[0]).toMatchSnapshot(); }); - it('acquires a developer relay connection for the given host type and ID', () => { - expect(hostConnectSpy).toBeCalledWith(hostType, mockHost.id); + it('acquires a connection for the selected host', () => { + expect(hostConnectSpy).toBeCalledWith(mockHost, deviceType); }); it('logs a message when the host disconnects', () => { - mockWS.emit('finish'); + mockStream.emit('finish'); expect(mockLog.mock.calls[1]).toMatchSnapshot(); }); }); describe(`when multiple ${deviceType}s are connected`, () => { - let mockSelectedHost: developerRelay.Host; + let mockSelectedHost: Host; beforeEach(() => { const mockHosts = mockRelayHosts[deviceType]; mockSelectedHost = mockHosts[1]; - mockRelayHostsResponse[deviceType](mockHosts); + listOfTypeSpy.mockResolvedValueOnce(mockHosts); mockPrompt.mockResolvedValueOnce({ - hostID: { - id: mockSelectedHost.id, - displayName: mockSelectedHost.displayName, - }, + host: mockSelectedHost, }); return doConnect(deviceType); }); @@ -137,33 +127,34 @@ describe.each<[DeviceType, HostType]>([ expect(mockPrompt).toBeCalled(); }); - it('acquires a developer relay connection for the given host type and ID', () => { - expect(hostConnectSpy).toBeCalledWith(hostType, mockSelectedHost.id); + it('acquires a connection for the selected host', () => { + expect(hostConnectSpy).toBeCalledWith(mockSelectedHost, deviceType); }); }); it('logs an error if the hosts call throws', async () => { - relayHostsSpy.mockRejectedValueOnce(new Error('some error')); + listOfTypeSpy.mockRejectedValueOnce(new Error('some error')); await doConnect(deviceType); expect(mockLog.mock.calls[0]).toMatchSnapshot(); }); }); it('does not show busy hosts', async () => { - mockRelayHostsResponse.device([mockAppHost, mockAppHost2, mockBusyAppHost]); + listOfTypeSpy.mockResolvedValueOnce([ + mockAppHost, + mockAppHost2, + mockBusyAppHost, + ]); mockPrompt.mockResolvedValueOnce({ - hostID: { - id: mockAppHost.id, - displayName: mockAppHost.displayName, - }, + host: mockAppHost, }); await doConnect('device'); expect(mockPrompt).toBeCalledWith( expect.objectContaining({ choices: [mockAppHost, mockAppHost2].map((host) => ({ - value: { id: host.id, displayName: host.displayName }, + value: host, name: host.displayName, })), }), @@ -171,7 +162,7 @@ it('does not show busy hosts', async () => { }); it('does not auto-connect a busy host', async () => { - mockRelayHostsResponse.device([mockBusyAppHost]); + listOfTypeSpy.mockResolvedValueOnce([mockBusyAppHost]); await doConnect('device'); expect(mockLog.mock.calls[0]).toMatchSnapshot(); }); diff --git a/packages/sdk-cli/src/commands/connect.ts b/packages/sdk-cli/src/commands/connect.ts index 3fce6ba5..4e95f525 100644 --- a/packages/sdk-cli/src/commands/connect.ts +++ b/packages/sdk-cli/src/commands/connect.ts @@ -1,72 +1,58 @@ import { startCase } from 'lodash'; import vorpal from '@moleculer/vorpal'; -import * as developerRelay from '../api/developerRelay'; -import HostConnections from '../models/HostConnections'; - -export type DeviceType = 'device' | 'phone'; +import HostConnections, { Host } from '../models/HostConnections'; +import { DeviceType } from '../models/HostTypes'; export const connectAction = async ( cli: vorpal, deviceType: DeviceType, hostConnections: HostConnections, ) => { - let hosts: { - appHost: developerRelay.Host[]; - companionHost: developerRelay.Host[]; - }; + let host: Host; + let availableHosts: Host[]; try { - hosts = await developerRelay.hosts(); - } catch (error) { + const matchedHosts = await hostConnections.listOfType(deviceType); + availableHosts = matchedHosts.filter((host) => host.available); + } catch (ex) { cli.log( // tslint:disable-next-line:max-line-length `An error was encountered when loading the list of available ${deviceType} hosts: ${ - (error as Error).message + (ex as Error).message }`, ); return false; } - const hostTypes: { [key: string]: keyof typeof hosts } = { - device: 'appHost', - phone: 'companionHost', - }; - - const hostType = hostTypes[deviceType]; - const matchedHosts = hosts[hostType].filter( - (host) => host.state === 'available', - ); - - if (matchedHosts.length === 0) { + if (availableHosts.length === 0) { cli.activeCommand.log(`No ${deviceType}s are connected and available`); return false; } - let host: { id: string; displayName: string }; - if (matchedHosts.length === 1) { - host = matchedHosts[0]; + if (availableHosts.length === 1) { + host = availableHosts[0]; cli.activeCommand.log( `Auto-connecting only known ${deviceType}: ${host.displayName}`, ); } else { host = ( await cli.activeCommand.prompt<{ - hostID: { id: string; displayName: string }; + host: Host; }>({ type: 'list', - name: 'hostID', + name: 'host', message: `Which ${deviceType} do you wish to sideload to?`, - choices: matchedHosts.map((host) => ({ + choices: availableHosts.map((host) => ({ name: host.displayName, - value: { id: host.id, displayName: host.displayName }, + value: host, })), }) - ).hostID; + ).host; } - const connection = await hostConnections.connect(hostType, host.id); - connection.ws.once('finish', () => + const connection = await hostConnections.connect(host, deviceType); + connection.stream.once('finish', () => cli.log(`${startCase(deviceType)} '${host.displayName}' disconnected`), ); diff --git a/packages/sdk-cli/src/commands/hosts.ts b/packages/sdk-cli/src/commands/hosts.ts index 0c92c63d..e73d28cf 100644 --- a/packages/sdk-cli/src/commands/hosts.ts +++ b/packages/sdk-cli/src/commands/hosts.ts @@ -1,14 +1,21 @@ import vorpal from '@moleculer/vorpal'; -import { hosts } from '../api/developerRelay'; - -export default (cli: vorpal) => { - cli.command('hosts', 'lists hosts and their status').action(async () => { - const { appHost, companionHost } = await hosts(); - - cli.activeCommand.log('Devices:'); - cli.activeCommand.log(appHost); - cli.activeCommand.log('Phones:'); - cli.activeCommand.log(companionHost); - }); -}; +import HostConnections from '../models/HostConnections'; + +async function hostsAction( + cli: vorpal, + { hostConnections }: { hostConnections: HostConnections }, +) { + const hosts = await hostConnections.list(); + + cli.activeCommand.log('Hosts:'); + cli.activeCommand.log(hosts); +} + +export default function (stores: { hostConnections: HostConnections }) { + return (cli: vorpal) => { + cli + .command('hosts', 'lists hosts and their status') + .action(async () => hostsAction(cli, stores)); + }; +} diff --git a/packages/sdk-cli/src/models/HostConnections.test.ts b/packages/sdk-cli/src/models/HostConnections.test.ts index d72e614f..d2efc8fd 100644 --- a/packages/sdk-cli/src/models/HostConnections.test.ts +++ b/packages/sdk-cli/src/models/HostConnections.test.ts @@ -3,18 +3,37 @@ import stream from 'stream'; import { RemoteHost } from '@fitbit/fdb-debugger'; import * as developerRelay from '../api/developerRelay'; -import HostConnections, { HostType } from '../models/HostConnections'; +import HostConnections, { Host } from '../models/HostConnections'; +import { DeviceType, HostType } from '../models//HostTypes'; +import * as USBDebugHost from './USBDebugHost'; jest.mock('@fitbit/fdb-debugger'); -const mockHostID = 'mockHostID'; const hostConnectedSpy = jest.fn(); let hostConnections: HostConnections; -let relayConnectSpy: jest.SpyInstance; let remoteHostSpy: jest.SpyInstance; - -function mockSentinel(spy: jest.SpyInstance) { +let mockHost: Host; +let mockStream: jest.Mocked; +let mockRemoteHost: {}; +let developerRelaySpy: jest.SpyInstance; +let usbSpy: jest.SpyInstance; + +const relayHost = { + displayName: 'RelayHost', + connect: jest.fn(), + available: true, + roles: ['COMPANION_HOST'], +}; + +const usbHost = { + displayName: 'USBHost', + connect: jest.fn(), + available: true, + roles: ['APP_HOST'], +}; + +function mockSentinel(spy: jest.SpyInstance | jest.MockedFunction) { const sentinel = {}; spy.mockResolvedValueOnce(sentinel); return sentinel; @@ -23,53 +42,109 @@ function mockSentinel(spy: jest.SpyInstance) { beforeEach(() => { hostConnections = new HostConnections(); hostConnections.onHostAdded.attach(hostConnectedSpy); - relayConnectSpy = jest.spyOn(developerRelay, 'connect'); remoteHostSpy = jest.spyOn(RemoteHost, 'connect'); + mockHost = { + displayName: 'Mock Host', + available: true, + connect: jest.fn(), + roles: ['APP_HOST'], + }; + developerRelaySpy = jest.spyOn(developerRelay, 'hosts'); + usbSpy = jest.spyOn(USBDebugHost, 'list'); + mockStream = mockSentinel(mockHost.connect) as jest.Mocked; + mockStream.destroy = jest.fn(); }); -function doConnect(type: HostType) { - return hostConnections.connect(type, mockHostID); -} +describe('connect()', () => { + describe.each(['device', 'phone'])( + 'when the device type argument is %s', + (deviceType) => { + const hostTypes: { [key in DeviceType]: HostType } = { + device: 'appHost', + phone: 'companionHost', + }; + const hostType = hostTypes[deviceType]; + + beforeEach(() => { + mockRemoteHost = mockSentinel(remoteHostSpy); + return hostConnections.connect(mockHost, deviceType); + }); + + it('acquires a connection from the provided host', () => { + expect(mockHost.connect).toBeCalledWith(); + }); + + it('creates a debugger client from the developer relay connection', () => { + expect(remoteHostSpy).toBeCalledWith(mockStream, undefined); + }); + + it('stores the connection in the application state', () => { + expect(hostConnections[hostType]!.stream).toBe(mockStream); + expect(hostConnections[hostType]!.host).toBe(mockRemoteHost); + }); + + it('emits a host-connected event with the HostType and HostConnection', () => { + expect(hostConnectedSpy).toBeCalledWith({ + hostType, + host: mockRemoteHost, + }); + }); -describe.each(['appHost', 'companionHost'])( - 'when the host type argument is %s', - (hostType) => { - let mockWS: jest.Mocked; - let mockRemoteHost: {}; - - beforeEach(() => { - mockWS = mockSentinel(relayConnectSpy) as jest.Mocked; - mockWS.destroy = jest.fn(); - - mockRemoteHost = mockSentinel(remoteHostSpy); - return doConnect(hostType); - }); - - it('acquires a developer relay connection for the given host ID', () => { - expect(relayConnectSpy).toBeCalledWith(mockHostID); - }); - - it('creates a debugger client from the developer relay connection', () => { - expect(remoteHostSpy).toBeCalledWith(mockWS, undefined); - }); - - it('stores the connection in the application state', () => { - expect(hostConnections[hostType]!.ws).toBe(mockWS); - expect(hostConnections[hostType]!.host).toBe(mockRemoteHost); - }); - - it('emits a host-connected event with the HostType and HostConnection', () => { - expect(hostConnectedSpy).toBeCalledWith({ - hostType, - host: mockRemoteHost, + it('closes any existing connection', async () => { + mockSentinel(mockHost.connect); + mockSentinel(remoteHostSpy); + await hostConnections.connect(mockHost, deviceType); + expect(mockStream.destroy).toBeCalled(); }); - }); - - it('closes any existing connection', async () => { - mockSentinel(relayConnectSpy); - mockSentinel(remoteHostSpy); - await doConnect(hostType); - expect(mockWS.destroy).toBeCalled(); - }); - }, -); + }, + ); + + it('closes the underlying transport if devbridge initialisation fails', async () => { + remoteHostSpy.mockRejectedValueOnce(new Error('init failed :(')); + await expect( + hostConnections.connect(mockHost, 'device'), + ).rejects.toThrowError(); + expect(mockStream.destroy).toBeCalled(); + }); +}); + +describe('list()', () => { + it('returns a list of both USB and Developer Relay hosts', () => { + developerRelaySpy.mockResolvedValue([relayHost]); + usbSpy.mockResolvedValue([usbHost]); + expect(hostConnections.list()).resolves.toEqual([relayHost, usbHost]); + }); + + it('throws if fetching developer relay hosts fails', () => { + developerRelaySpy.mockRejectedValue(new Error('fail devrelay')); + return expect(hostConnections.list()).rejects.toThrowError( + 'An error was encountered when loading the list of available Developer Relay hosts: fail devrelay', + ); + }); + + it('throws if fetching USB hosts fails', () => { + developerRelaySpy.mockResolvedValue([relayHost]); + usbSpy.mockRejectedValue(new Error('fail usb')); + return expect(hostConnections.list()).rejects.toThrowError( + 'An error was encountered when loading the list of available USB hosts: fail usb', + ); + }); +}); + +describe('listOfType()', () => { + it('returns only app hosts', () => { + developerRelaySpy.mockResolvedValue([relayHost]); + usbSpy.mockResolvedValue([usbHost]); + return expect(hostConnections.listOfType('device')).resolves.toEqual([ + usbHost, + ]); + }); + + it('returns only companion hosts', () => { + developerRelaySpy.mockResolvedValue([relayHost]); + usbSpy.mockResolvedValue([usbHost]); + return expect(hostConnections.listOfType('phone')).resolves.toEqual([ + relayHost, + ]); + }); +}); diff --git a/packages/sdk-cli/src/models/HostConnections.ts b/packages/sdk-cli/src/models/HostConnections.ts index 26709e13..116511d6 100644 --- a/packages/sdk-cli/src/models/HostConnections.ts +++ b/packages/sdk-cli/src/models/HostConnections.ts @@ -3,17 +3,17 @@ import 'stream.finished/auto'; import { RemoteHost } from '@fitbit/fdb-debugger'; import dateformat from 'dateformat'; import fs from 'fs'; -import stream from 'stream'; +import stream, { Duplex } from 'stream'; import { SyncEvent } from 'ts-events'; import * as developerRelay from '../api/developerRelay'; +import * as USBDebugHost from './USBDebugHost'; +import { DeviceType, HostType } from './HostTypes'; import StreamTap from './StreamTap'; -export type HostType = 'appHost' | 'companionHost'; - export class HostConnection { - private constructor(public ws: stream.Duplex, public host: RemoteHost) {} + private constructor(public stream: stream.Duplex, public host: RemoteHost) {} static getDumpStreamTap() { const shouldDumpLogFile = process.env.FITBIT_DEVBRIDGE_DUMP === '1'; @@ -48,9 +48,11 @@ export class HostConnection { return transforms; } - static async connect(hostID: string) { - const ws = await developerRelay.connect(hostID); - return new this(ws, await RemoteHost.connect(ws, this.getDumpStreamTap())); + static async connect(stream: Duplex) { + return new this( + stream, + await RemoteHost.connect(stream, this.getDumpStreamTap()), + ); } } @@ -59,20 +61,83 @@ export type HostAddedEvent = { host: RemoteHost; }; +export interface Host { + connect(): Promise; + displayName: string; + available: boolean; + roles: string[]; +} + class HostConnections { onHostAdded = new SyncEvent(); appHost?: HostConnection; companionHost?: HostConnection; - async connect(hostType: HostType, hostID: string) { + async connect(host: Host, deviceType: DeviceType) { + const hostTypes: { [key in DeviceType]: HostType } = { + device: 'appHost', + phone: 'companionHost', + }; + const hostType = hostTypes[deviceType]; + const existingHost = this[hostType]; - if (existingHost) existingHost.ws.destroy(); + if (existingHost) existingHost.stream.destroy(); + + const stream = await host.connect(); + + let hostConnection: HostConnection; + try { + hostConnection = await HostConnection.connect(stream); + } catch (ex) { + stream.destroy(); + throw ex; + } - const hostConnection = await HostConnection.connect(hostID); this[hostType] = hostConnection; this.onHostAdded.post({ hostType, host: hostConnection.host }); return hostConnection; } + + async list() { + const hosts: Host[] = []; + + try { + for (const device of await developerRelay.hosts()) { + hosts.push(device); + } + } catch (error) { + throw new Error( + `An error was encountered when loading the list of available Developer Relay hosts: ${ + (error as Error).message + }`, + ); + } + + try { + for (const device of await USBDebugHost.list()) { + hosts.push(device); + } + } catch (error) { + throw new Error( + `An error was encountered when loading the list of available USB hosts: ${ + (error as Error).message + }`, + ); + } + + return hosts; + } + + async listOfType(deviceType: DeviceType) { + const hosts = await this.list(); + + const hostRole = { + device: 'APP_HOST', + phone: 'COMPANION_HOST', + }[deviceType]; + + return hosts.filter((host) => host.roles.includes(hostRole)); + } } export default HostConnections; diff --git a/packages/sdk-cli/src/models/HostTypes.ts b/packages/sdk-cli/src/models/HostTypes.ts new file mode 100644 index 00000000..f130b3f8 --- /dev/null +++ b/packages/sdk-cli/src/models/HostTypes.ts @@ -0,0 +1,2 @@ +export type DeviceType = 'device' | 'phone'; +export type HostType = 'appHost' | 'companionHost'; diff --git a/packages/sdk-cli/src/models/PULSEAdapter.test.ts b/packages/sdk-cli/src/models/PULSEAdapter.test.ts new file mode 100644 index 00000000..824eba7b --- /dev/null +++ b/packages/sdk-cli/src/models/PULSEAdapter.test.ts @@ -0,0 +1,198 @@ +import { EventEmitter } from 'events'; +import { Duplex } from 'stream'; + +import { Interface, Link, Socket } from '@fitbit/pulse'; + +import PULSEAdapter from './PULSEAdapter'; + +jest.mock('@fitbit/pulse', () => ({ + Interface: jest.fn(), + Socket: jest.fn(), +})); + +let mockInterface: jest.Mocked; +let mockLink: jest.Mocked; +let mockSocket: jest.Mocked; + +const testData = Buffer.from('hello world!'); + +class MockSocket extends EventEmitter { + closed = false; + readonly mtu = 500; + onReceive = jest.fn(); + send = jest.fn(); + close = jest.fn(); +} + +function eventPromise(stream: Duplex, eventName: string) { + return new Promise((resolve) => stream.once(eventName, resolve)); +} + +beforeEach(() => { + mockSocket = new MockSocket() as unknown as jest.Mocked; + mockLink = { + openSocket: jest.fn(() => mockSocket), + } as unknown as jest.Mocked; + mockInterface = { + getLink: jest.fn(() => mockLink), + close: jest.fn(), + } as unknown as jest.Mocked; + Interface.create = jest.fn(() => mockInterface); +}); + +it('opens a connection', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + expect(pulseStream).toBeInstanceOf(Duplex); + expect(Interface.create).toBeCalledWith(stream, { + requestedTransports: ['reliable'], + }); + expect(mockInterface.getLink).toBeCalled(); + expect(mockLink.openSocket).toBeCalledWith('reliable', 0x3e20); +}); + +it('sends a packet', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + pulseStream.write(testData); + expect(mockSocket.send).toBeCalledWith( + Buffer.concat([Buffer.from([0, 0]), testData]), + ); +}); + +it('sends multiple packets where message size exceeds MTU', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + + const msgSize = mockSocket.mtu * 2; + const msg = Buffer.alloc(msgSize); + for (let i = 0; i < msgSize; i += 1) msg[i] = i % 255; + + pulseStream.write(msg); + + const chunkSize = mockSocket.mtu - 2; + expect(mockSocket.send).toBeCalledWith( + Buffer.concat([Buffer.from([0, 2]), msg.slice(0, chunkSize)]), + ); + expect(mockSocket.send).toBeCalledWith( + Buffer.concat([Buffer.from([0, 1]), msg.slice(chunkSize, 2 * chunkSize)]), + ); + expect(mockSocket.send).toBeCalledWith( + Buffer.concat([Buffer.from([0, 0]), msg.slice(2 * chunkSize)]), + ); +}); + +it('emits an error if sending a packet fails', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + mockSocket.send.mockImplementationOnce(() => { + throw new Error('send failed'); + }); + pulseStream.write(testData); + + await expect(eventPromise(pulseStream, 'error')).resolves.toThrowError( + 'send failed', + ); +}); + +it('receives a packet', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + mockSocket.emit('data', Buffer.concat([Buffer.from([0, 0]), testData])); + + return new Promise((resolve) => + pulseStream.on('data', (chunk) => { + expect(chunk).toEqual(testData); + stream.destroy(); + resolve(); + }), + ); +}); + +it('receives a packet sent in multiple chunks', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + + mockSocket.emit( + 'data', + Buffer.concat([ + Buffer.from([0, 1]), + testData.slice(0, testData.length / 2), + ]), + ); + mockSocket.emit( + 'data', + Buffer.concat([Buffer.from([0, 0]), testData.slice(testData.length / 2)]), + ); + + return new Promise((resolve) => + pulseStream.on('data', (chunk) => { + expect(chunk).toEqual(testData); + stream.destroy(); + resolve(); + }), + ); +}); + +it('emits an error if receiving an invalid packet', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + + const errorPromise = eventPromise(pulseStream, 'error'); + mockSocket.emit('data', Buffer.alloc(1)); + + return expect(errorPromise).resolves.toEqual( + new Error('Packet with length below minimum allowed'), + ); +}); + +it('emits an error if receiving an out of sequence packet', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + + const errorPromise = eventPromise(pulseStream, 'error'); + mockSocket.emit( + 'data', + Buffer.concat([ + Buffer.from([0, 2]), + testData.slice(0, testData.length / 2), + ]), + ); + mockSocket.emit( + 'data', + Buffer.concat([Buffer.from([0, 0]), testData.slice(testData.length / 2)]), + ); + + return expect(errorPromise).resolves.toEqual( + new Error('Received out of sequence packet, expected 1, got 0'), + ); +}); + +it('ignores non-data packets', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + + const errorHandler = jest.fn(); + pulseStream.on('error', errorHandler); + mockSocket.emit('data', Buffer.from([1, 1])); + + pulseStream.destroy(); + + return eventPromise(pulseStream, 'close'); +}); + +it('closes interface when stream closes', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + pulseStream.destroy(); + await eventPromise(pulseStream, 'close'); + expect(mockInterface.close).toBeCalled(); +}); + +it('closes stream when socket closes', async () => { + const stream = new Duplex(); + const pulseStream = await PULSEAdapter.create(stream); + mockSocket.emit('close'); + await eventPromise(pulseStream, 'close'); + expect(mockInterface.close).toBeCalled(); +}); diff --git a/packages/sdk-cli/src/models/PULSEAdapter.ts b/packages/sdk-cli/src/models/PULSEAdapter.ts new file mode 100644 index 00000000..8f3e6049 --- /dev/null +++ b/packages/sdk-cli/src/models/PULSEAdapter.ts @@ -0,0 +1,101 @@ +import { Duplex } from 'stream'; + +import { Interface, Socket } from '@fitbit/pulse'; + +const DEVBRIDGE_HEADER_SIZE = 2; +const DEVBRIDGE_PORT = 0x3e20; + +const enum DevbridgePULSEPacketType { + Data = 0, +} + +export default class PULSEAdapter extends Duplex { + private rxBuffer = Buffer.alloc(0); + private lastPacketsRemaining?: number; + + private constructor(private intf: Interface, private socket: Socket) { + super({ objectMode: true }); + + this.on('close', () => void this.intf.close()); + this.socket.on('close', () => this.destroy()); + + socket.on('data', (packet: Buffer) => { + if (packet.byteLength < DEVBRIDGE_HEADER_SIZE) { + this.emit( + 'error', + new Error(`Packet with length below minimum allowed`), + ); + return; + } + + const [type, packetsFollowing] = new Uint8Array(packet); + + // Ignore non-data for now + if (type !== DevbridgePULSEPacketType.Data) return; + + if (this.lastPacketsRemaining === undefined) { + this.lastPacketsRemaining = packetsFollowing; + } else if (this.lastPacketsRemaining !== packetsFollowing + 1) { + const expected = this.lastPacketsRemaining - 1; + this.emit( + 'error', + new Error( + `Received out of sequence packet, expected ${expected}, got ${packetsFollowing}`, + ), + ); + } else { + this.lastPacketsRemaining -= 1; + } + + this.rxBuffer = Buffer.concat([ + this.rxBuffer, + packet.slice(DEVBRIDGE_HEADER_SIZE), + ]); + + if (packetsFollowing === 0) { + this.push(this.rxBuffer); + this.rxBuffer = Buffer.alloc(0); + this.lastPacketsRemaining = undefined; + } + }); + } + + static async create(stream: Duplex) { + const intf = Interface.create(stream, { + requestedTransports: ['reliable'], + }); + const link = await intf.getLink(); + const socket = await link.openSocket('reliable', DEVBRIDGE_PORT); + return new PULSEAdapter(intf, socket); + } + + // tslint:disable-next-line:function-name + _read() { + // stub + } + + // tslint:disable-next-line:function-name + _write(buf: Buffer, encoding: unknown, callback: (err?: Error) => void) { + try { + const chunkSize = this.socket.mtu - DEVBRIDGE_HEADER_SIZE; + let bytesSent = 0; + let chunksLeft = Math.ceil(buf.byteLength / chunkSize); + + while (chunksLeft > 0) { + const body = buf.slice(bytesSent, bytesSent + chunkSize); + chunksLeft -= 1; + bytesSent += body.byteLength; + + const header = new Uint8Array([ + DevbridgePULSEPacketType.Data, + chunksLeft, // packetsFollowing + ]); + + this.socket.send(Buffer.concat([header, body])); + } + callback(); + } catch (ex) { + callback(ex as Error); + } + } +} diff --git a/packages/sdk-cli/src/models/USBDebugHost.test.ts b/packages/sdk-cli/src/models/USBDebugHost.test.ts new file mode 100644 index 00000000..aa44afb1 --- /dev/null +++ b/packages/sdk-cli/src/models/USBDebugHost.test.ts @@ -0,0 +1,67 @@ +import { Duplex } from 'stream'; +import PULSEAdapter from './PULSEAdapter'; +import { list } from './USBDebugHost'; +import USBSerialDevice from './USBSerialDevice'; + +jest.mock('./PULSEAdapter'); +jest.mock('./USBSerialDevice'); + +let deviceListSpy: jest.SpyInstance; +let pulseAdaptorCreateSpy: jest.SpyInstance; + +interface USBHost { + name: string; + opened: boolean; + connect: jest.MockedFunction<() => Promise>; +} + +let mockDeviceA: USBHost; +let mockDeviceB: USBHost; + +function mockSentinel(spy: jest.SpyInstance | jest.MockedFunction) { + const sentinel = {}; + spy.mockResolvedValueOnce(sentinel); + return sentinel; +} + +beforeEach(() => { + deviceListSpy = jest.spyOn(USBSerialDevice, 'list'); + pulseAdaptorCreateSpy = jest.spyOn(PULSEAdapter, 'create'); + mockDeviceA = { + name: 'Device A', + opened: false, + connect: jest.fn(), + }; + mockDeviceB = { + name: 'Device B', + opened: true, + connect: jest.fn(), + }; +}); + +it('returns a list of connected hosts', () => { + deviceListSpy.mockResolvedValue([mockDeviceA, mockDeviceB]); + expect(list()).resolves.toEqual([ + { + displayName: 'Device A', + available: true, + connect: expect.any(Function), + roles: ['APP_HOST'], + }, + { + displayName: 'Device B', + available: false, + connect: expect.any(Function), + roles: ['APP_HOST'], + }, + ]); +}); + +it('returns function that creates connection to host', async () => { + deviceListSpy.mockResolvedValue([mockDeviceA]); + const deviceStreamSentinel = mockSentinel(mockDeviceA.connect); + const adapterSentinel = mockSentinel(pulseAdaptorCreateSpy); + const [host] = await list(); + await expect(host.connect()).resolves.toBe(adapterSentinel); + expect(pulseAdaptorCreateSpy).toBeCalledWith(deviceStreamSentinel); +}); diff --git a/packages/sdk-cli/src/models/USBDebugHost.ts b/packages/sdk-cli/src/models/USBDebugHost.ts new file mode 100644 index 00000000..8bbafc5f --- /dev/null +++ b/packages/sdk-cli/src/models/USBDebugHost.ts @@ -0,0 +1,15 @@ +import PULSEAdapter from './PULSEAdapter'; +import USBSerialDevice from './USBSerialDevice'; + +export async function list() { + const devices = await USBSerialDevice.list(); + return devices.map((device) => ({ + displayName: device.name, + available: !device.opened, + roles: ['APP_HOST'], + connect: async () => { + const stream = await device.connect(); + return PULSEAdapter.create(stream); + }, + })); +} diff --git a/packages/sdk-cli/src/models/USBSerialDevice.test.ts b/packages/sdk-cli/src/models/USBSerialDevice.test.ts new file mode 100644 index 00000000..b72a785e --- /dev/null +++ b/packages/sdk-cli/src/models/USBSerialDevice.test.ts @@ -0,0 +1,250 @@ +import * as usb from 'usb'; +import { Duplex } from 'stream'; + +import USBSerialDevice from './USBSerialDevice'; + +jest.mock('usb', () => ({ + WebUSB: jest.fn(), +})); + +const validEndpoints: USBEndpoint[] = [ + { direction: 'in', type: 'bulk', endpointNumber: 0, packetSize: 64 }, + { direction: 'out', type: 'bulk', endpointNumber: 0, packetSize: 64 }, +]; +const testData = Buffer.from('hello world!'); + +let mockUSBDevices: USBDevice[] = []; +let mockDevice: jest.Mocked; + +function makeMockDevice( + vendorId: number, + interfaceName: string, + endpoints: USBEndpoint[], + opened: boolean, +): jest.Mocked { + return { + vendorId, + opened, + reset: jest.fn(), + open: jest.fn(), + close: jest.fn(), + selectConfiguration: jest.fn(), + claimInterface: jest.fn(), + transferIn: jest.fn(), + transferOut: jest.fn(), + productName: 'A USB Device', + manufacturerName: 'Fitbit', + configurations: [ + { + interfaces: [ + { + alternates: [ + { + interfaceName, + endpoints, + }, + ], + interfaceNumber: 0, + }, + ], + configurationValue: 0, + }, + ], + } as unknown as jest.Mocked; +} + +function eventPromise(stream: Duplex, eventName: string) { + return new Promise((resolve) => stream.once(eventName, resolve)); +} + +beforeEach(() => { + const webUSBSpy = jest.spyOn(usb, 'WebUSB'); + webUSBSpy.mockReturnValue({ + getDevices: jest.fn(() => Promise.resolve(mockUSBDevices)), + } as unknown as usb.WebUSB); + mockDevice = makeMockDevice(0x2687, 'CDC-FDB', validEndpoints, false); +}); + +it('lists suitable fitbit devices', () => { + mockUSBDevices = [ + // Matches + makeMockDevice(0x2687, 'CDC-FDB', validEndpoints, false), + // Missing out endpoint + makeMockDevice( + 0x2687, + 'CDC-FDB', + [{ direction: 'in', type: 'bulk', endpointNumber: 0, packetSize: 64 }], + false, + ), + // Missing in endpoint + makeMockDevice( + 0x2687, + 'CDC-FDB', + [{ direction: 'out', type: 'bulk', endpointNumber: 0, packetSize: 64 }], + false, + ), + // Wrong vendor ID + makeMockDevice(0x1337, 'CDC-FDB', validEndpoints, false), + // Wrong interface name + makeMockDevice(0x2687, 'CDC-FOO', validEndpoints, false), + ]; + expect(USBSerialDevice.list()).resolves.toEqual([ + { + name: 'A USB Device', + opened: false, + connect: expect.any(Function), + }, + ]); +}); + +it('connects to a device', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + expect(stream).toBeInstanceOf(Duplex); + expect(mockDevice.open).toBeCalled(); + expect(mockDevice.claimInterface).toBeCalled(); + expect(mockDevice.selectConfiguration).toBeCalled(); + stream.destroy(); +}); + +it('closes device once stream is closed', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + stream.destroy(); + + // cleanup is async, wait for it... + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockDevice.reset).toBeCalled(); + expect(mockDevice.close).toBeCalled(); +}); + +it('closes device again if setup fails', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + mockDevice.claimInterface.mockRejectedValueOnce(new Error('fail to claim')); + await expect(fitbitDevice.connect()).rejects.toThrowError(); + expect(mockDevice.close).toBeCalled(); +}); + +it('emits an error if cleanup fails', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + mockDevice.close.mockRejectedValueOnce(new Error('fail to close')); + + const stream = await fitbitDevice.connect(); + stream.destroy(); + await expect(eventPromise(stream, 'error')).resolves.toThrowError( + 'Failed to close USB device: Error: fail to close', + ); +}); + +it('writes bytes to device', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + + mockDevice.transferIn.mockImplementationOnce(() => new Promise(() => {})); + mockDevice.transferOut.mockResolvedValueOnce({ + status: 'ok', + bytesWritten: testData.length, + }); + + stream.write(testData); + stream.destroy(); + + await eventPromise(stream, 'close'); + + expect(mockDevice.transferOut).toBeCalledWith(expect.any(Number), testData); +}); + +it('emits error when writing bytes to device fails', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + + mockDevice.transferIn.mockImplementationOnce(() => new Promise(() => {})); + mockDevice.transferOut.mockResolvedValueOnce({ + status: 'stall', + bytesWritten: 0, + }); + + stream.write(testData); + + await expect(eventPromise(stream, 'error')).resolves.toThrowError( + 'USB write failed: stall, wrote 0 of 12 bytes', + ); +}); + +it('emits error when writing bytes to device fails', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + + mockDevice.transferIn.mockImplementationOnce(() => new Promise(() => {})); + mockDevice.transferOut.mockRejectedValueOnce(new Error('failed write')); + + stream.write(testData); + + await expect(eventPromise(stream, 'error')).resolves.toThrowError( + 'USB write failed: Error: failed write', + ); +}); + +it('reads bytes from device', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + + mockDevice.transferIn + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolve({ + status: 'ok', + data: new DataView( + testData.buffer, + testData.byteOffset, + testData.byteLength, + ), + }); + }), + ) + .mockImplementation(() => new Promise(() => {})); + + return new Promise((resolve) => + stream.on('data', (chunk) => { + expect(chunk).toEqual(testData); + stream.destroy(); + resolve(); + }), + ); +}); + +it('emits error when reading bytes from device fails', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + + mockDevice.transferIn.mockResolvedValueOnce({ status: 'stall' }); + stream.resume(); + + await expect(eventPromise(stream, 'error')).resolves.toThrowError( + 'USB read failed: stall', + ); +}); + +it('emits error when reading bytes from device throws', async () => { + mockUSBDevices = [mockDevice]; + const [fitbitDevice] = await USBSerialDevice.list(); + const stream = await fitbitDevice.connect(); + + mockDevice.transferIn.mockRejectedValueOnce(new Error(`failed read`)); + stream.resume(); + + await expect(eventPromise(stream, 'error')).resolves.toThrowError( + 'USB read failed: Error: failed read', + ); +}); diff --git a/packages/sdk-cli/src/models/USBSerialDevice.ts b/packages/sdk-cli/src/models/USBSerialDevice.ts new file mode 100644 index 00000000..2163e8b1 --- /dev/null +++ b/packages/sdk-cli/src/models/USBSerialDevice.ts @@ -0,0 +1,172 @@ +import { WebUSB } from 'usb'; +import { Duplex } from 'stream'; + +const FITBIT_VENDOR_ID = 0x2687; +const FITBIT_FDB_INTERFACE_NAME = 'CDC-FDB'; + +function findEndpoint( + endpoints: USBEndpoint[], + direction: USBDirection, + type: USBEndpointType, +): USBEndpoint | undefined { + const matches = endpoints.filter( + (ep) => ep.direction === direction && ep.type === type, + ); + return matches.length > 0 ? matches[0] : undefined; +} + +interface DeviceConfig { + configuration: number; + interface: number; + writeEndpoint: USBEndpoint; + readEndpoint: USBEndpoint; + name: string; +} + +function findDeviceEndpoints(device: USBDevice): DeviceConfig | undefined { + for (const { interfaces, configurationValue } of device.configurations) { + let ifname: string | undefined; + for (const { alternates, interfaceNumber } of interfaces) { + for (const { interfaceName, endpoints } of alternates) { + if (interfaceName && interfaceName.length > 0) { + ifname = interfaceName; + } + + if (ifname !== FITBIT_FDB_INTERFACE_NAME) continue; + + const writeEndpoint = findEndpoint(endpoints, 'out', 'bulk'); + const readEndpoint = findEndpoint(endpoints, 'in', 'bulk'); + + if ( + writeEndpoint !== undefined && + readEndpoint !== undefined && + device.productName !== undefined && + device.manufacturerName !== undefined + ) { + return { + writeEndpoint, + readEndpoint, + configuration: configurationValue, + interface: interfaceNumber, + name: `${device.manufacturerName} ${device.productName}`, + }; + } + } + } + } + + return undefined; +} + +export default class USBSerialDevice extends Duplex { + constructor(private device: USBDevice, private config: DeviceConfig) { + super(); + this.on('close', () => void this.cleanup()); + } + + private static async create( + device: USBDevice, + config: DeviceConfig, + ): Promise { + try { + await device.open(); + await device.selectConfiguration(config.configuration); + await device.claimInterface(config.interface); + + return new USBSerialDevice(device, config); + } catch (ex) { + await device.close(); + throw ex; + } + } + + static async list() { + const webUSB = new WebUSB({ allowAllDevices: true }); + const usbDevices = await webUSB.getDevices(); + const fitbitDevices: { + opened: boolean; + name: string; + connect: () => Promise; + }[] = []; + + for (const usbDevice of usbDevices) { + if (usbDevice.vendorId !== FITBIT_VENDOR_ID) continue; + const deviceEndpoints = findDeviceEndpoints(usbDevice); + if (deviceEndpoints) { + fitbitDevices.push({ + name: usbDevice.productName!, + opened: usbDevice.opened, + connect: () => USBSerialDevice.create(usbDevice, deviceEndpoints), + }); + } + } + + return fitbitDevices; + } + + private async cleanup() { + try { + // We have to reset before we can close because otherwise it fails + // if a transfer is in progress + await this.device.reset(); + await this.device.close(); + } catch (ex) { + this.emit( + 'error', + new Error(`Failed to close USB device: ${String(ex)}`), + ); + } + } + + // tslint:disable-next-line:function-name + _read(size: number) { + const { packetSize } = this.config.readEndpoint; + + void this.device + .transferIn( + this.config.readEndpoint.endpointNumber, + Math.min(packetSize, size), + ) + .then( + (result) => { + if (result.status === 'ok' && result.data !== undefined) { + this.push( + Buffer.from( + result.data.buffer, + result.data.byteOffset, + result.data.byteLength, + ), + ); + } else { + this.emit( + 'error', + new Error(`USB read failed: ${String(result.status)}`), + ); + } + }, + (ex) => { + this.emit('error', new Error(`USB read failed: ${String(ex)}`)); + }, + ); + } + + // tslint:disable-next-line:function-name + _write(chunk: Buffer, encoding: unknown, callback: (err?: Error) => void) { + this.device + .transferOut(this.config.writeEndpoint.endpointNumber, chunk) + .then( + ({ status, bytesWritten }) => { + if (status === 'ok' && bytesWritten === chunk.byteLength) { + callback(); + } else { + callback( + new Error( + `USB write failed: ${status}, wrote ${bytesWritten} of ${chunk.byteLength} bytes`, + ), + ); + } + }, + (ex) => callback(new Error(`USB write failed: ${String(ex)}`)), + ); + } +} diff --git a/packages/sdk-cli/src/models/__mocks__/usb.ts b/packages/sdk-cli/src/models/__mocks__/usb.ts new file mode 100644 index 00000000..e69de29b diff --git a/yarn.lock b/yarn.lock index f43f97c4..36cc7266 100644 --- a/yarn.lock +++ b/yarn.lock @@ -321,6 +321,14 @@ error-subclass "^2.2.0" tslib "^2.0.3" +"@fitbit/pulse@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@fitbit/pulse/-/pulse-0.1.0.tgz#d85a61bd4dd2a205f5bf6bd65d5901f4e696a65f" + integrity sha512-Rt3L82kS2G1poWRNPSdXaod/DwdTswMbgQ4TNuZIzL/NyWdOMwfoftiqBGzcAbZt9CppamI1kzb1VYAZMZOxQQ== + dependencies: + crc-32 "^1.2.0" + tslib "^2.3.1" + "@gar/promisify@^1.0.1", "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -1851,6 +1859,11 @@ resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.3.tgz#3193c0a3c03a7d1189016c62b4fba4b149ef5e33" integrity sha512-DNviAE5OUcZ5s+XEQHRhERLg8fOp8gSgvyJ4aaFASx5wwaObm+PBwTIMXiOFm1QrSee5oYwEAYb7LMzX2O88gA== +"@types/w3c-web-usb@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/w3c-web-usb/-/w3c-web-usb-1.0.6.tgz#5d8560d0d9f585ffc80865bc773db7bc975b680c" + integrity sha512-cSjhgrr8g4KbPnnijAr/KJDNKa/bBa+ixYkywFRvrhvi9n1WEl7yYbtRyzE6jqNQiSxxJxoAW3STaOQwJHndaw== + "@types/ws@^7.2.6": version "7.4.7" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" @@ -2889,6 +2902,11 @@ coveralls@^3.1.1: minimist "^1.2.5" request "^2.88.2" +crc-32@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -5368,7 +5386,7 @@ node-abi@^3.3.0: dependencies: semver "^7.3.5" -node-addon-api@^4.3.0: +node-addon-api@^4.2.0, node-addon-api@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== @@ -5380,6 +5398,11 @@ node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@~2.6.0: dependencies: whatwg-url "^5.0.0" +node-gyp-build@^4.3.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.5.0.tgz#7a64eefa0b21112f89f58379da128ac177f20e40" + integrity sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg== + node-gyp@^8.4.1: version "8.4.1" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.1.tgz#3d49308fc31f768180957d6b5746845fbd429937" @@ -7085,7 +7108,7 @@ tslib@^1.13.0, tslib@^1.7.1, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0: +tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== @@ -7317,6 +7340,15 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +usb@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/usb/-/usb-2.4.3.tgz#fab8c1820276d0cb34f87a5ddba0152633d4a829" + integrity sha512-BmCjjxsriODcrb+TdXdzSDDys+MOUeRvo22ywmyIxYcuVW9YKrr2Wp2gQZUfHrQbvovKu87Po9mk5OPftJdDeQ== + dependencies: + "@types/w3c-web-usb" "1.0.6" + node-addon-api "^4.2.0" + node-gyp-build "^4.3.0" + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"