hidgnubbydevice.js revision 6e8cce623b6e4fe0c9e4af605d675dd9d0338c38
1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview Implements a low-level gnubby driver based on chrome.hid.
7 */
8'use strict';
9
10/**
11 * Low level gnubby 'driver'. One per physical USB device.
12 * @param {Gnubbies} gnubbies The gnubbies instances this device is enumerated
13 *     in.
14 * @param {!chrome.hid.HidConnectInfo} dev The connection to the device.
15 * @param {number} id The device's id.
16 * @constructor
17 * @implements {GnubbyDevice}
18 */
19function HidGnubbyDevice(gnubbies, dev, id) {
20  /** @private {Gnubbies} */
21  this.gnubbies_ = gnubbies;
22  this.dev = dev;
23  this.id = id;
24  this.txqueue = [];
25  this.clients = [];
26  this.lockCID = 0;     // channel ID of client holding a lock, if != 0.
27  this.lockMillis = 0;  // current lock period.
28  this.lockTID = null;  // timer id of lock timeout.
29  this.closing = false;  // device to be closed by receive loop.
30  this.updating = false;  // device firmware is in final stage of updating.
31}
32
33/**
34 * Namespace for the HidGnubbyDevice implementation.
35 * @const
36 */
37HidGnubbyDevice.NAMESPACE = 'hid';
38
39/** Destroys this low-level device instance. */
40HidGnubbyDevice.prototype.destroy = function() {
41  if (!this.dev) return;  // Already dead.
42
43  this.gnubbies_.removeOpenDevice(
44      {namespace: HidGnubbyDevice.NAMESPACE, device: this.id});
45  this.closing = true;
46
47  console.log(UTIL_fmt('HidGnubbyDevice.destroy()'));
48
49  // Synthesize a close error frame to alert all clients,
50  // some of which might be in read state.
51  //
52  // Use magic CID 0 to address all.
53  this.publishFrame_(new Uint8Array([
54        0, 0, 0, 0,  // broadcast CID
55        GnubbyDevice.CMD_ERROR,
56        0, 1,  // length
57        GnubbyDevice.GONE]).buffer);
58
59  // Set all clients to closed status and remove them.
60  while (this.clients.length != 0) {
61    var client = this.clients.shift();
62    if (client) client.closed = true;
63  }
64
65  if (this.lockTID) {
66    window.clearTimeout(this.lockTID);
67    this.lockTID = null;
68  }
69
70  var dev = this.dev;
71  this.dev = null;
72
73  chrome.hid.disconnect(dev.connectionId, function() {
74    console.log(UTIL_fmt('Device ' + dev.connectionId + ' closed'));
75  });
76};
77
78/**
79 * Push frame to all clients.
80 * @param {ArrayBuffer} f Data to push
81 * @private
82 */
83HidGnubbyDevice.prototype.publishFrame_ = function(f) {
84  var old = this.clients;
85
86  var remaining = [];
87  var changes = false;
88  for (var i = 0; i < old.length; ++i) {
89    var client = old[i];
90    if (client.receivedFrame(f)) {
91      // Client still alive; keep on list.
92      remaining.push(client);
93    } else {
94      changes = true;
95      console.log(UTIL_fmt(
96          '[' + client.cid.toString(16) + '] left?'));
97    }
98  }
99  if (changes) this.clients = remaining;
100};
101
102/**
103 * Register a client for this gnubby.
104 * @param {*} who The client.
105 */
106HidGnubbyDevice.prototype.registerClient = function(who) {
107  for (var i = 0; i < this.clients.length; ++i) {
108    if (this.clients[i] === who) return;  // Already registered.
109  }
110  this.clients.push(who);
111  if (this.clients.length == 1) {
112    // First client? Kick off read loop.
113    this.readLoop_();
114  }
115};
116
117/**
118 * De-register a client.
119 * @param {*} who The client.
120 * @return {number} The number of remaining listeners for this device, or -1
121 * Returns number of remaining listeners for this device.
122 *     if this had no clients to start with.
123 */
124HidGnubbyDevice.prototype.deregisterClient = function(who) {
125  var current = this.clients;
126  if (current.length == 0) return -1;
127  this.clients = [];
128  for (var i = 0; i < current.length; ++i) {
129    var client = current[i];
130    if (client !== who) this.clients.push(client);
131  }
132  return this.clients.length;
133};
134
135/**
136 * @param {*} who The client.
137 * @return {boolean} Whether this device has who as a client.
138 */
139HidGnubbyDevice.prototype.hasClient = function(who) {
140  if (this.clients.length == 0) return false;
141  for (var i = 0; i < this.clients.length; ++i) {
142    if (who === this.clients[i])
143      return true;
144  }
145  return false;
146};
147
148/**
149 * Reads all incoming frames and notifies clients of their receipt.
150 * @private
151 */
152HidGnubbyDevice.prototype.readLoop_ = function() {
153  //console.log(UTIL_fmt('entering readLoop'));
154  if (!this.dev) return;
155
156  if (this.closing) {
157    this.destroy();
158    return;
159  }
160
161  // No interested listeners, yet we hit readLoop().
162  // Must be clean-up. We do this here to make sure no transfer is pending.
163  if (!this.clients.length) {
164    this.closing = true;
165    this.destroy();
166    return;
167  }
168
169  // firmwareUpdate() sets this.updating when writing the last block before
170  // the signature. We process that reply with the already pending
171  // read transfer but we do not want to start another read transfer for the
172  // signature block, since that request will have no reply.
173  // Instead we will see the device drop and re-appear on the bus.
174  // Current libusb on some platforms gets unhappy when transfer are pending
175  // when that happens.
176  // TODO: revisit once Chrome stabilizes its behavior.
177  if (this.updating) {
178    console.log(UTIL_fmt('device updating. Ending readLoop()'));
179    return;
180  }
181
182  var self = this;
183  chrome.hid.receive(
184    this.dev.connectionId,
185    function(report_id, data) {
186      if (chrome.runtime.lastError || !data) {
187        console.log(UTIL_fmt('got lastError'));
188        console.log(chrome.runtime.lastError);
189        window.setTimeout(function() { self.destroy(); }, 0);
190        return;
191      }
192      var u8 = new Uint8Array(data);
193      console.log(UTIL_fmt('<' + UTIL_BytesToHex(u8)));
194
195      self.publishFrame_(data);
196
197      // Read more.
198      window.setTimeout(function() { self.readLoop_(); }, 0);
199    }
200  );
201};
202
203/**
204 * Check whether channel is locked for this request or not.
205 * @param {number} cid Channel id
206 * @param {number} cmd Request command
207 * @return {boolean} true if not locked for this request.
208 * @private
209 */
210HidGnubbyDevice.prototype.checkLock_ = function(cid, cmd) {
211  if (this.lockCID) {
212    // We have an active lock.
213    if (this.lockCID != cid) {
214      // Some other channel has active lock.
215
216      if (cmd != GnubbyDevice.CMD_SYNC) {
217        // Anything but SYNC gets an immediate busy.
218        var busy = new Uint8Array(
219            [(cid >> 24) & 255,
220             (cid >> 16) & 255,
221             (cid >> 8) & 255,
222             cid & 255,
223             GnubbyDevice.CMD_ERROR,
224             0, 1,  // length
225             GnubbyDevice.BUSY]);
226        // Log the synthetic busy too.
227        console.log(UTIL_fmt('<' + UTIL_BytesToHex(busy)));
228        this.publishFrame_(busy.buffer);
229        return false;
230      }
231
232      // SYNC gets to go to the device to flush OS tx/rx queues.
233      // The usb firmware always responds to SYNC, regardless of lock status.
234    }
235  }
236  return true;
237};
238
239/**
240 * Update or grab lock.
241 * @param {number} cid Channel ID
242 * @param {number} cmd Command
243 * @param {number} arg Command argument
244 * @private
245 */
246HidGnubbyDevice.prototype.updateLock_ = function(cid, cmd, arg) {
247  if (this.lockCID == 0 || this.lockCID == cid) {
248    // It is this caller's or nobody's lock.
249    if (this.lockTID) {
250      window.clearTimeout(this.lockTID);
251      this.lockTID = null;
252    }
253
254    if (cmd == GnubbyDevice.CMD_LOCK) {
255      var nseconds = arg;
256      if (nseconds != 0) {
257        this.lockCID = cid;
258        // Set tracking time to be .1 seconds longer than usb device does.
259        this.lockMillis = nseconds * 1000 + 100;
260      } else {
261        // Releasing lock voluntarily.
262        this.lockCID = 0;
263      }
264    }
265
266    // (re)set the lock timeout if we still hold it.
267    if (this.lockCID) {
268      var self = this;
269      this.lockTID = window.setTimeout(
270          function() {
271            console.warn(UTIL_fmt(
272                'lock for CID ' + cid.toString(16) + ' expired!'));
273            self.lockTID = null;
274            self.lockCID = 0;
275          },
276          this.lockMillis);
277    }
278  }
279};
280
281/**
282 * Queue command to be sent.
283 * If queue was empty, initiate the write.
284 * @param {number} cid The client's channel ID.
285 * @param {number} cmd The command to send.
286 * @param {ArrayBuffer|Uint8Array} data Command arguments
287 */
288HidGnubbyDevice.prototype.queueCommand = function(cid, cmd, data) {
289  if (!this.dev) return;
290  if (!this.checkLock_(cid, cmd)) return;
291
292  var u8 = new Uint8Array(data);
293  var f = new Uint8Array(64);
294
295  HidGnubbyDevice.setCid_(f, cid);
296  f[4] = cmd;
297  f[5] = (u8.length >> 8);
298  f[6] = (u8.length & 255);
299
300  var lockArg = (u8.length > 0) ? u8[0] : 0;
301
302  // Fragment over our 64 byte frames.
303  var n = 7;
304  var seq = 0;
305  for (var i = 0; i < u8.length; ++i) {
306    f[n++] = u8[i];
307    if (n == f.length) {
308      this.queueFrame_(f.buffer, cid, cmd, lockArg);
309
310      f = new Uint8Array(64);
311      HidGnubbyDevice.setCid_(f, cid);
312      cmd = f[4] = seq++;
313      n = 5;
314    }
315  }
316  if (n != 5) {
317    this.queueFrame_(f.buffer, cid, cmd, lockArg);
318  }
319};
320
321/**
322 * Sets the channel id in the frame.
323 * @param {Uint8Array} frame Data frame
324 * @param {number} cid The client's channel ID.
325 * @private
326 */
327HidGnubbyDevice.setCid_ = function(frame, cid) {
328  frame[0] = cid >>> 24;
329  frame[1] = cid >>> 16;
330  frame[2] = cid >>> 8;
331  frame[3] = cid;
332};
333
334/**
335 * Updates the lock, and queues the frame for sending. Also begins sending if
336 * no other writes are outstanding.
337 * @param {ArrayBuffer} frame Data frame
338 * @param {number} cid The client's channel ID.
339 * @param {number} cmd The command to send.
340 * @param {number} arg Command argument
341 * @private
342 */
343HidGnubbyDevice.prototype.queueFrame_ = function(frame, cid, cmd, arg) {
344  this.updateLock_(cid, cmd, arg);
345  var wasEmpty = (this.txqueue.length == 0);
346  this.txqueue.push(frame);
347  if (wasEmpty) this.writePump_();
348};
349
350/**
351 * Stuff queued frames from txqueue[] to device, one by one.
352 * @private
353 */
354HidGnubbyDevice.prototype.writePump_ = function() {
355  if (!this.dev) return;  // Ignore.
356
357  if (this.txqueue.length == 0) return;  // Done with current queue.
358
359  var frame = this.txqueue[0];
360
361  var self = this;
362  function transferComplete() {
363    if (chrome.runtime.lastError) {
364      console.log(UTIL_fmt('got lastError'));
365      console.log(chrome.runtime.lastError);
366      window.setTimeout(function() { self.destroy(); }, 0);
367      return;
368    }
369    self.txqueue.shift();  // drop sent frame from queue.
370    if (self.txqueue.length != 0) {
371      window.setTimeout(function() { self.writePump_(); }, 0);
372    }
373  };
374
375  var u8 = new Uint8Array(frame);
376
377  // See whether this requires scrubbing before logging.
378  var alternateLog = Gnubby.hasOwnProperty('redactRequestLog') &&
379                     Gnubby['redactRequestLog'](u8);
380  if (alternateLog) {
381    console.log(UTIL_fmt('>' + alternateLog));
382  } else {
383    console.log(UTIL_fmt('>' + UTIL_BytesToHex(u8)));
384  }
385
386  var u8f = new Uint8Array(64);
387  for (var i = 0; i < u8.length; ++i) {
388    u8f[i] = u8[i];
389  }
390
391  chrome.hid.send(
392      this.dev.connectionId,
393      0,  // report Id. Must be 0 for our use.
394      u8f.buffer,
395      transferComplete
396  );
397};
398
399/**
400 * @param {function(Array)} cb Enumeration callback
401 */
402HidGnubbyDevice.enumerate = function(cb) {
403  var permittedDevs;
404  var numEnumerated = 0;
405  var allDevs = [];
406
407  function enumerated(devs) {
408    allDevs = allDevs.concat(devs);
409    if (++numEnumerated == permittedDevs.length) {
410      cb(allDevs);
411    }
412  }
413
414  GnubbyDevice.getPermittedUsbDevices(function(devs) {
415    permittedDevs = devs;
416    for (var i = 0; i < devs.length; i++) {
417      chrome.hid.getDevices(devs[i], enumerated);
418    }
419  });
420};
421
422/**
423 * @param {Gnubbies} gnubbies The gnubbies instances this device is enumerated
424 *     in.
425 * @param {number} which The index of the device to open.
426 * @param {!chrome.hid.HidDeviceInfo} dev The device to open.
427 * @param {function(number, GnubbyDevice=)} cb Called back with the
428 *     result of opening the device.
429 */
430HidGnubbyDevice.open = function(gnubbies, which, dev, cb) {
431  chrome.hid.connect(dev.deviceId, function(handle) {
432    if (chrome.runtime.lastError) {
433      console.log(chrome.runtime.lastError);
434    }
435    if (!handle) {
436      console.warn(UTIL_fmt('failed to connect device. permissions issue?'));
437      cb(-GnubbyDevice.NODEVICE);
438      return;
439    }
440    var nonNullHandle = /** @type {!chrome.hid.HidConnectInfo} */ (handle);
441    var gnubby = new HidGnubbyDevice(gnubbies, nonNullHandle, which);
442    cb(-GnubbyDevice.OK, gnubby);
443  });
444};
445
446/**
447 * @param {*} dev A browser API device object
448 * @return {GnubbyDeviceId} A device identifier for the device.
449 */
450HidGnubbyDevice.deviceToDeviceId = function(dev) {
451  var hidDev = /** @type {!chrome.hid.HidDeviceInfo} */ (dev);
452  var deviceId = {
453    namespace: HidGnubbyDevice.NAMESPACE,
454    device: hidDev.deviceId
455  };
456  return deviceId;
457};
458
459/**
460 * Registers this implementation with gnubbies.
461 * @param {Gnubbies} gnubbies Gnubbies registry
462 */
463HidGnubbyDevice.register = function(gnubbies) {
464  var HID_GNUBBY_IMPL = {
465    isSharedAccess: true,
466    enumerate: HidGnubbyDevice.enumerate,
467    deviceToDeviceId: HidGnubbyDevice.deviceToDeviceId,
468    open: HidGnubbyDevice.open
469  };
470  gnubbies.registerNamespace(HidGnubbyDevice.NAMESPACE, HID_GNUBBY_IMPL);
471};
472