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