Refactor Bluetooth HID Mouse API

A nice, new, separated API for emulating a mouse is now here.
Kit redirection is explicit in it, too. This represents the first step
in ridding ourselves of the getattr hack and solving
http://crbug.com/764055 .
RN42 can now keep holding buttons while scrolling, too!

Remove the old HID keyboard/mouse API from the public interface in
PeripheralKit, and remove the pylint silencer on BluefruitLE.

The RN42 no longer clamps values to [-127,127]. The API is no longer
publicly exposed, so we're handling those checks elsewhere.

BUG=chromium:752719,chromium:764055
TEST=Run this code on the Chameleon board, with an RN42 attached:
$ make && make remote-install CHAMELEON_HOST=$CHAMELEON_IP
Execute the non-flaky non-stress tests that use this code,
see that the tests pass:
$ test_that --board ${BOARD} --args "chameleon_host=${CHAMELEON_IP}" \
${DUT_IP} bluetooth_AdapterPairing.mouse \
bluetooth_AdapterPairing.mouse.pairing_twice \
bluetooth_AdapterHIDReports.mouse

Change-Id: I07b70f0c5fba4abde34f640199f7ccf4c916224d
Reviewed-on: https://chromium-review.googlesource.com/663788
Commit-Ready: Alexander Lent <alent@google.com>
Tested-by: Alexander Lent <alent@google.com>
Reviewed-by: Wai-Hong Tam <waihong@google.com>
Reviewed-by: Shyh-In Hwang <josephsih@chromium.org>
diff --git a/chameleond/utils/bluetooth_bluefruitle.py b/chameleond/utils/bluetooth_bluefruitle.py
index 875f9e8..f9bf77b 100644
--- a/chameleond/utils/bluetooth_bluefruitle.py
+++ b/chameleond/utils/bluetooth_bluefruitle.py
@@ -26,7 +26,6 @@
   pass
 
 
-# pylint: disable=abstract-method
 class BluefruitLE(PeripheralKit):
   """This is an abstraction of the Adafruit Bluefruit LE Friend kit.
 
diff --git a/chameleond/utils/bluetooth_hid.py b/chameleond/utils/bluetooth_hid.py
index 28a4b98..f23be3f 100644
--- a/chameleond/utils/bluetooth_hid.py
+++ b/chameleond/utils/bluetooth_hid.py
@@ -68,6 +68,9 @@
       AttributeError if the attribute is not found.
       (This is the default behavior and kits should follow it.)
     """
+    if name.startswith("Mouse"):
+      error = "Kit API is not public. Use public API from BluetoothHIDMouse."
+      raise AttributeError(error)
     return getattr(self._kit, name)
 
   def Init(self, factory_reset=True):
@@ -180,10 +183,9 @@
 class BluetoothHIDMouse(BluetoothHID):
   """A bluetooth HID mouse emulator class."""
 
-  # Definitions of buttons
-  BUTTONS_RELEASED = 0x0
-  LEFT_BUTTON = 0x01
-  RIGHT_BUTTON = 0x02
+  # Max and min values for HID mouse report values
+  HID_MAX_REPORT_VALUE = 127
+  HID_MIN_REPORT_VALUE = -127
 
   def __init__(self, authentication_mode, kit_impl):
     """Initialization of BluetoothHIDMouse
@@ -195,91 +197,102 @@
     super(BluetoothHIDMouse, self).__init__(
         PeripheralKit.MOUSE, authentication_mode, kit_impl)
 
-  def Move(self, delta_x=0, delta_y=0, buttons=0):
-    """Move the mouse (delta_x, delta_y) pixels with buttons status.
+  def _EnsureHIDValueInRange(self, value):
+    """Ensures given value is in the range [-127,127] (inclusive).
 
     Args:
-      delta_x: the pixels to move horizontally
-               positive values: moving right; max value = 127.
-               negative values: moving left; max value = -127.
-      delta_y: the pixels to move vertically
-               positive values: moving down; max value = 127.
-               negative values: moving up; max value = -127.
-      buttons: the press/release status of buttons
-    """
-    if delta_x or delta_y:
-      mouse_codes = self.RawMouseCodes(buttons=buttons,
-                                       x_stop=delta_x, y_stop=delta_y)
-      self.SerialSendReceive(mouse_codes, msg='BluetoothHIDMouse.Move')
-      time.sleep(self.send_delay)
+      value: The value that should be checked.
 
-  def _PressButtons(self, buttons):
-    """Press down the specified buttons
+    Raises:
+      BluetoothHIDException if value is outside of the acceptable range.
+    """
+    if value < self.HID_MIN_REPORT_VALUE or value > self.HID_MAX_REPORT_VALUE:
+      error = "Value %s is outside of acceptable range [-127,127]." % value
+      logging.error(error)
+      raise BluetoothHIDException(error)
+
+  def Move(self, delta_x=0, delta_y=0):
+    """Move the mouse (delta_x, delta_y) steps.
+
+    If buttons are being pressed, they will stay pressed during this operation.
+    This move is relative to the current position by the HID standard.
+    Valid step values must be in the range [-127,127].
 
     Args:
-      buttons: the buttons to press
-    """
-    if buttons:
-      mouse_codes = self.RawMouseCodes(buttons=buttons)
-      self.SerialSendReceive(mouse_codes, msg='BluetoothHIDMouse._PressButtons')
-      time.sleep(self.send_delay)
+      delta_x: The number of steps to move horizontally.
+               Negative values move left, positive values move right.
+      delta_y: The number of steps to move vertically.
+               Negative values move up, positive values move down.
 
-  def _ReleaseButtons(self):
-    """Release buttons."""
-    mouse_codes = self.RawMouseCodes(buttons=self.BUTTONS_RELEASED)
-    self.SerialSendReceive(mouse_codes, msg='BluetoothHIDMouse._ReleaseButtons')
+    Raises:
+      BluetoothHIDException if a given delta is not in [-127,127].
+    """
+    self._EnsureHIDValueInRange(delta_x)
+    self._EnsureHIDValueInRange(delta_y)
+    self._kit.MouseMove(delta_x, delta_y)
     time.sleep(self.send_delay)
 
-  def PressLeftButton(self):
-    """Press the left button."""
-    self._PressButtons(self.LEFT_BUTTON)
+  def _PressLeftButton(self):
+    """Press the left button"""
+    self._kit.MousePressButtons({PeripheralKit.MOUSE_BUTTON_LEFT})
+    time.sleep(self.send_delay)
 
-  def ReleaseLeftButton(self):
-    """Release the left button."""
-    self._ReleaseButtons()
+  def _PressRightButton(self):
+    """Press the right button"""
+    self._kit.MousePressButtons({PeripheralKit.MOUSE_BUTTON_RIGHT})
+    time.sleep(self.send_delay)
 
-  def PressRightButton(self):
-    """Press the right button."""
-    self._PressButtons(self.RIGHT_BUTTON)
-
-  def ReleaseRightButton(self):
-    """Release the right button."""
-    self._ReleaseButtons()
+  def _ReleaseAllButtons(self):
+    """Press the right button"""
+    self._kit.MouseReleaseAllButtons()
+    time.sleep(self.send_delay)
 
   def LeftClick(self):
     """Make a left click."""
-    self.PressLeftButton()
-    self.ReleaseLeftButton()
+    self._PressLeftButton()
+    self._ReleaseAllButtons()
 
   def RightClick(self):
     """Make a right click."""
-    self.PressRightButton()
-    self.ReleaseRightButton()
+    self._PressRightButton()
+    self._ReleaseAllButtons()
 
   def ClickAndDrag(self, delta_x=0, delta_y=0):
-    """Click and drag (delta_x, delta_y)
+    """Left click, drag (delta_x, delta_y) steps, and release.
+
+    This move is relative to the current position by the HID standard.
+    Valid step values must be in the range [-127,127].
 
     Args:
-      delta_x: the pixels to move horizontally
-      delta_y: the pixels to move vertically
-    """
-    self.PressLeftButton()
-    # Keep the left button pressed while moving.
-    self.Move(delta_x=delta_x, delta_y=delta_y, buttons=self.LEFT_BUTTON)
-    self.ReleaseLeftButton()
+      delta_x: The number of steps to move horizontally.
+               Negative values move left, positive values move right.
+      delta_y: The number of steps to move vertically.
+               Negative values move up, positive values move down.
 
-  def Scroll(self, wheel):
-    """Scroll the wheel.
+    Raises:
+      BluetoothHIDException if a given delta is not in [-127,127].
+    """
+    self._EnsureHIDValueInRange(delta_x)
+    self._EnsureHIDValueInRange(delta_y)
+    self._PressLeftButton()
+    self.Move(delta_x, delta_y)
+    self._ReleaseAllButtons()
+
+  def Scroll(self, steps):
+    """Scroll the mouse wheel steps number of steps.
+
+    Buttons currently pressed will stay pressed during this operation.
+    Valid step values must be in the range [-127,127].
 
     Args:
-      wheel: the steps to scroll
-             The scroll direction depends on which scroll method is employed,
-             traditional scrolling or Australian scrolling.
+      steps: The number of steps to scroll the wheel.
+             With traditional scrolling:
+               Negative values scroll down, positive values scroll up.
+             With reversed (formerly "Australian") scrolling this is reversed.
     """
-    if wheel:
-      mouse_codes = self.RawMouseCodes(wheel=wheel)
-      self.SerialSendReceive(mouse_codes, msg='BluetoothHIDMouse.Scroll')
-      time.sleep(self.send_delay)
+    self._EnsureHIDValueInRange(steps)
+    self._kit.MouseScroll(steps)
+    time.sleep(self.send_delay)
 
 
 def DemoBluetoothHIDKeyboard(remote_address, chars):
diff --git a/chameleond/utils/bluetooth_peripheral_kit.py b/chameleond/utils/bluetooth_peripheral_kit.py
index d8915ee..ab8eb9d 100644
--- a/chameleond/utils/bluetooth_peripheral_kit.py
+++ b/chameleond/utils/bluetooth_peripheral_kit.py
@@ -83,11 +83,18 @@
   # The default PIN code
   DEFAULT_PIN_CODE = None
 
+  # Mouse constants
+  MOUSE_VALUE_MIN = -127
+  MOUSE_VALUE_MAX = 127
+  MOUSE_BUTTON_LEFT = "MOUSE_BUTTON_LEFT"
+  MOUSE_BUTTON_RIGHT = "MOUSE_BUTTON_RIGHT"
+
   def __init__(self):
     self._command_mode = False
     self._closed = False
     self._serial = None
     self._tty = None
+    self._buttons_pressed = set()
 
   def __del__(self):
     self.Close()
@@ -633,18 +640,74 @@
     """
     raise NotImplementedError("Not implemented")
 
-  # TODO(alent): Refactor this part of the API, it's too RN-42-specific!
+  # Helper methods for implementing a system that remembers button state
+  def _MouseButtonStateUnion(self, buttons_to_press):
+    """Add to the current set of pressed buttons.
 
-  def RawKeyCodes(self, modifiers=None, keys=None):
+    Args:
+      buttons_to_press: A set of buttons, as PeripheralKit MOUSE_BUTTON_*
+                        values, that will stay pressed.
+    """
+    self._buttons_pressed = self._buttons_pressed.union(buttons_to_press)
+
+  def _MouseButtonStateSubtract(self, buttons_to_release):
+    """Remove from the current set of pressed buttons.
+
+    Args:
+      buttons_to_release: A set of buttons, as PeripheralKit MOUSE_BUTTON_*
+                          values, that will be released.
+    """
+    self._buttons_pressed = self._buttons_pressed.difference(buttons_to_release)
+
+  def _MouseButtonStateClear(self):
+    """Clear the mouse button pressed state."""
+    self._buttons_pressed = set()
+
+  # Methods starting with "Mouse" should not be exposed to Autotest directly,
+  # especially those dealing with button sets.
+  def MouseMove(self, delta_x, delta_y):
+    """Move the mouse (delta_x, delta_y) steps.
+
+    If buttons are being pressed, they will stay pressed during this operation.
+    This move is relative to the current position by the HID standard.
+    Valid step values must be in the range [-127,127].
+
+    Args:
+      delta_x: The number of steps to move horizontally.
+               Negative values move left, positive values move right.
+      delta_y: The number of steps to move vertically.
+               Negative values move up, positive values move down.
+    """
     raise NotImplementedError("Not implemented")
 
-  def RawMouseCodes(self, buttons=0, x_stop=0, y_stop=0, wheel=0):
+  def MouseScroll(self, steps):
+    """Scroll the mouse wheel steps number of steps.
+
+    Buttons currently pressed will stay pressed during this operation.
+    Valid step values must be in the range [-127,127].
+
+    Args:
+      steps: The number of steps to scroll the wheel.
+             With traditional scrolling:
+               Negative values scroll down, positive values scroll up.
+             With reversed (formerly "Australian") scrolling this is reversed.
+    """
     raise NotImplementedError("Not implemented")
 
-  def PressShorthandCodes(self, modifiers=None, keys=None):
+  def MousePressButtons(self, buttons):
+    """Press the specified mouse buttons.
+
+    The kit will continue to press these buttons until otherwise instructed, or
+    until its state has been reset.
+
+    Args:
+      buttons: A set of buttons, as PeripheralKit MOUSE_BUTTON_* values, that
+               will be pressed (and held down).
+    """
     raise NotImplementedError("Not implemented")
 
-  def ReleaseShorthandCodes(self):
+  def MouseReleaseAllButtons(self):
+    """Release all mouse buttons."""
     raise NotImplementedError("Not implemented")
 
 
diff --git a/chameleond/utils/bluetooth_rn42.py b/chameleond/utils/bluetooth_rn42.py
index e13168c..a3006e1 100644
--- a/chameleond/utils/bluetooth_rn42.py
+++ b/chameleond/utils/bluetooth_rn42.py
@@ -126,6 +126,11 @@
   RAW_REPORT_FORMAT_MOUSE_LENGTH = 5
   RAW_REPORT_FORMAT_MOUSE_DESCRIPTOR = 2
 
+  # Definitions of mouse button HID encodings
+  RAW_HID_BUTTONS_RELEASED = 0x0
+  RAW_HID_LEFT_BUTTON = 0x01
+  RAW_HID_RIGHT_BUTTON = 0x02
+
   # TODO(alent): Move std scan codes to PeripheralKit when Keyboard implemented
   # modifiers
   LEFT_CTRL = 0x01
@@ -956,7 +961,79 @@
             ''.join(real_scan_codes) +
             padding_0s)
 
-  def RawMouseCodes(self, buttons=0, x_stop=0, y_stop=0, wheel=0):
+  def _MouseButtonsRawHidValues(self):
+    """Gives the raw HID values for whatever buttons are pressed."""
+    currently_pressed = 0x0
+    for button in self._buttons_pressed:
+      if button == PeripheralKit.MOUSE_BUTTON_LEFT:
+        currently_pressed |= self.RAW_HID_LEFT_BUTTON
+      elif button == PeripheralKit.MOUSE_BUTTON_RIGHT:
+        currently_pressed |= self.RAW_HID_RIGHT_BUTTON
+      else:
+        error = "Unknown mouse button in state: %s" % button
+        logging.error(error)
+        raise RN42Exception(error)
+    return currently_pressed
+
+  def MouseMove(self, delta_x, delta_y):
+    """Move the mouse (delta_x, delta_y) steps.
+
+    If buttons are being pressed, they will stay pressed during this operation.
+    This move is relative to the current position by the HID standard.
+    Valid step values must be in the range [-127,127].
+
+    Args:
+      delta_x: The number of steps to move horizontally.
+               Negative values move left, positive values move right.
+      delta_y: The number of steps to move vertically.
+               Negative values move up, positive values move down.
+    """
+    raw_buttons = self._MouseButtonsRawHidValues()
+    if delta_x or delta_y:
+      mouse_codes = self._RawMouseCodes(buttons=raw_buttons, x_stop=delta_x,
+                                        y_stop=delta_y)
+      self.SerialSendReceive(mouse_codes, msg='RN42: MouseMove')
+
+  def MouseScroll(self, steps):
+    """Scroll the mouse wheel steps number of steps.
+
+    Buttons currently pressed will stay pressed during this operation.
+    Valid step values must be in the range [-127,127].
+
+    Args:
+      steps: The number of steps to scroll the wheel.
+             With traditional scrolling:
+               Negative values scroll down, positive values scroll up.
+             With reversed (formerly "Australian") scrolling this is reversed.
+    """
+    raw_buttons = self._MouseButtonsRawHidValues()
+    if steps:
+      mouse_codes = self._RawMouseCodes(buttons=raw_buttons, wheel=steps)
+      self.SerialSendReceive(mouse_codes, msg='RN42: MouseScroll')
+
+  def MousePressButtons(self, buttons):
+    """Press the specified mouse buttons.
+
+    The kit will continue to press these buttons until otherwise instructed, or
+    until its state has been reset.
+
+    Args:
+      buttons: A set of buttons, as PeripheralKit MOUSE_BUTTON_* values, that
+               will be pressed (and held down).
+    """
+    self._MouseButtonStateUnion(buttons)
+    raw_buttons = self._MouseButtonsRawHidValues()
+    if raw_buttons:
+      mouse_codes = self._RawMouseCodes(buttons=raw_buttons)
+      self.SerialSendReceive(mouse_codes, msg='RN42: MousePressButtons')
+
+  def MouseReleaseAllButtons(self):
+    """Release all mouse buttons."""
+    self._MouseButtonStateClear()
+    mouse_codes = self._RawMouseCodes(buttons=self.RAW_HID_BUTTONS_RELEASED)
+    self.SerialSendReceive(mouse_codes, msg='RN42: MouseReleaseAllButtons')
+
+  def _RawMouseCodes(self, buttons=0, x_stop=0, y_stop=0, wheel=0):
     """Generate the codes in mouse raw report format.
 
     This method sends data in the raw report mode. The first start
@@ -965,7 +1042,7 @@
 
     For example, generate the codes of moving cursor 100 pixels left
     and 50 pixels down:
-      codes = RawMouseCodes(x_stop=-100, y_stop=50)
+      codes = _RawMouseCodes(x_stop=-100, y_stop=50)
 
     Args:
       buttons: the buttons to press and release
@@ -979,23 +1056,17 @@
     def SignedChar(value):
       """Converted the value to a legitimate signed character value.
 
+      Given value must be in [-127,127], or odd things will happen.
+
       Args:
         value: a signed integer
 
       Returns:
         a signed character value
       """
-      if value <= -127:
-        # If the negative value is too small, limit it to -127.
-        # Then perform two's complement: -127 + 256 = 129
-        return 129
-      elif value < 0:
+      if value < 0:
         # Perform two's complement.
         return value + 256
-      elif value > 127:
-        # If the positive value is too large, limit it to 127
-        # to prevent it from becoming negative.
-        return 127
       return value
 
     return (chr(self.UART_INPUT_RAW_MODE) +