motor_board: Add motor board module to control motor for button automation

The motor board has an I/O expander on I2C bus 3, offset 0x23.
We use it to control 5 motors for button automation.

The motor model is read from /etc/default/motor_model. Currently, there
are 'Atrus' and 'Jabra' models. The movement parameters are not tuned
yet.

BUG=chromium:685530
TEST=verify motor function with the next CL.

Change-Id: Ib8829c4246a8b1b1b79c8dfe2b9bcf8ec661b170
Reviewed-on: https://chromium-review.googlesource.com/433620
Commit-Ready: Cheng-Yi Chiang <cychiang@chromium.org>
Tested-by: Cheng-Yi Chiang <cychiang@chromium.org>
Reviewed-by: Wai-Hong Tam <waihong@google.com>
Reviewed-by: Shyh-In Hwang <josephsih@chromium.org>
diff --git a/chameleond/devices/motor_board.py b/chameleond/devices/motor_board.py
new file mode 100644
index 0000000..549c727
--- /dev/null
+++ b/chameleond/devices/motor_board.py
@@ -0,0 +1,343 @@
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+"""This module provides interface to control button motors."""
+
+import collections
+import logging
+import os
+import time
+
+import chameleon_common  # pylint: disable=W0611
+from chameleond.utils import chameleon_io as io
+from chameleond.utils import i2c
+
+"""
+Holds bit offsets for motor ports.
+Currently all motors use the same I/O expander.
+If some motor needs to use another I/O expander, or some ports of a
+motor need to use another I/O expander, index needs to be saved in
+MotorPorts too.
+"""
+MotorPorts = collections.namedtuple(
+    'MotorPorts', ['step', 'direction', 'enable'])
+
+
+"""
+Motor parameter:
+  num_pulse: Number of pulses to drive step motor.
+  period_ms: Duration of each pulse in ms.
+"""
+MotorParams = collections.namedtuple(
+    'MotorParams', ['num_pulse', 'period_ms'])
+
+
+class ButtonFunction(object):
+    """Button functions that motor touch/release."""
+    CALL = 'Call'
+    HANG_UP = 'Hang Up'
+    MUTE = 'Mute'
+    VOL_UP = 'Vol Up'
+    VOL_DOWN = 'Vol Down'
+
+
+class MotorBoardException(Exception):
+  """Errors in MotorBoard."""
+
+
+class MotorBoardNotExistException(MotorBoardException):
+  """Error that motor board does not exist."""
+
+
+class MotorBoard(object):
+  """Controls button motor through motor board.
+
+  A motor has three ports: Step, Direction, Enable.
+  There are five motors to control on motor board.
+  This class provides the interface to control these ports.
+  The ports are controlled through an I/O expander on I2C bus 3 address 0x23.
+
+  =============================================
+
+  I/O expander: address 0x23
+
+  bit offset     15  14  13  12  11  10   9   8
+
+  name          p17 p16 p15 p14 p13 p12 p11 p10
+
+  bit offset      7   6   5   4   3   2   1   0
+
+  name          p07 p06 p05 p04 p03 p02 p01 p00
+
+  =============================================
+
+  Common: Microstep resolution
+
+  Name      name     bit offset
+
+  MS1        p01         1
+  MS2        p02         2
+  MS3        p03         3
+
+  =============================================
+
+  Motor   Function    port  name   bit offset
+
+  0       Call        step   p04       4
+                       dir   p00       0
+                    enable   p05       5
+
+  1       Hangup      step   p06       6
+                       dir   p00       0
+                    enable   p07       7
+
+  2       Mute        step   p10       8
+                       dir   p00       0
+                    enable   p11       9
+
+  3       Vol Up      step   p12      10
+                       dir   p00       0
+                    enable   p13      11
+
+  4       Vol Down    step   p14      12
+                       dir   p00       0
+                    enable   p15      13
+
+  =============================================
+
+  """
+
+  # Mapping from motor function to ports.
+  _MOTOR_PORT_MAP = {
+      ButtonFunction.CALL: MotorPorts(4, 0, 5),
+      ButtonFunction.HANG_UP: MotorPorts(6, 0, 7),
+      ButtonFunction.MUTE: MotorPorts(8, 0, 9),
+      ButtonFunction.VOL_UP: MotorPorts(10, 0, 11),
+      ButtonFunction.VOL_DOWN: MotorPorts(12, 0, 13)}
+
+  # TODO(cychiang) Tune the duration and parameters.
+  _MODEL_PARAMS_MAP = {
+      'Atrus': {
+           ButtonFunction.CALL: MotorParams(60, 10),
+           ButtonFunction.HANG_UP: MotorParams(60, 10),
+           ButtonFunction.MUTE: MotorParams(60, 10),
+           ButtonFunction.VOL_UP: MotorParams(60, 10),
+           ButtonFunction.VOL_DOWN: MotorParams(60, 10),
+      },
+      'Jabra': {
+           ButtonFunction.CALL: MotorParams(60, 10),
+           ButtonFunction.HANG_UP: MotorParams(60, 10),
+           ButtonFunction.MUTE: MotorParams(60, 10),
+           ButtonFunction.VOL_UP: MotorParams(60, 10),
+           ButtonFunction.VOL_DOWN: MotorParams(60, 10),
+      }
+  }
+
+  _MODEL_TAG_PATH = '/etc/default/motor_model'
+
+  def __init__(self, i2c_bus):
+    """Constructs a MotorBoard.
+
+    Args:
+      i2c_bus: The I2cBus object.
+    """
+    self._motor_io = None
+    try:
+      self._motor_io = io.IoExpander(i2c_bus, io.IoExpander.SLAVE_ADDRESSES[3])
+    except i2c.I2cBusError:
+      logging.error('Can not access I2c bus at %#x on motor board.',
+                    i2c_bus.base_addr)
+      raise MotorBoardNotExistException('Can not initialize motor board')
+
+    self._ResetPorts()
+    self._motors = {}
+
+    model_name = self._GetModelName()
+    logging.info('Create motor board for model: %s', model_name)
+
+    self._CreateMotors(model_name)
+
+    logging.info('MotorBoard initialized')
+
+  def _GetModelName(self):
+    """Gets model name for this motor board.
+
+    Returns:
+      A string for model name.
+
+    Raises:
+      If model tag path does not exist or it contains unexpected model name.
+    """
+    if not os.path.exists(self._MODEL_TAG_PATH):
+      raise MotorBoardException('Model path does not exist: %s' % self._MODEL_TAG_PATH)
+    with open(self._MODEL_TAG_PATH) as f:
+      model_name = f.readline().strip()
+    if model_name not in self._MODEL_PARAMS_MAP:
+      raise MotorBoardException('Model name not expected: %s' % model_name)
+    return model_name
+
+  def _ResetPorts(self):
+    """Resets all ports on I/O expander to input direction.
+
+    The voltage level of all the ports are determined by their pull-up
+    or pull-down circuits.
+    """
+    self._motor_io.SetDirection(0xffff)
+
+  def _CreateMotors(self, model_name):
+    """Creates motors.
+
+    Args:
+      model_name: A key in self._MODEL_PARAMS_MAP.
+    """
+    if model_name not in self._MODEL_PARAMS_MAP:
+      raise MotorBoardException('Unexpected model name %s' % model_name)
+
+    model_map = self._MODEL_PARAMS_MAP[model_name]
+
+    for func, params in model_map.iteritems():
+      ports = self._MOTOR_PORT_MAP[func]
+      self._motors[func] = Motor(self._motor_io, ports, params)
+
+  def _CheckFunction(self, func):
+    """Check if a button function is supported on this motor board.
+
+    Args:
+      func: Button function for this motor. Defined in ButtonFunction.
+
+    Raises:
+      MotorBoardException if func is not supported on this motor board.
+    """
+    if func not in self._motors:
+      raise MotorBoardException('Button function %s not suported' % func)
+
+  def Touch(self, func):
+    """Let one of the motor moves to touch a button.
+
+    Args:
+      func: Button function for this motor. Defined in ButtonFunction.
+    """
+    self._CheckFunction(func)
+    self._motors[func].Touch()
+
+  def Release(self, func):
+    """Let one of the motor release button.
+
+    Args:
+      func: Button function for this motor. Defined in ButtonFunction.
+    """
+    self._CheckFunction(func)
+    self._motors[func].Release()
+
+
+class _MotorState(object):
+  """Motor states."""
+  RELEASED = 'Released'
+  TOUCHED = 'Touched'
+
+
+class _MotorDirection(object):
+  """Motor moving direction."""
+  UP = 'Up'
+  DOWN = 'Down'
+
+
+class MotorError(Exception):
+  """Error in Motor."""
+
+
+class Motor(object):
+  """Class to control one motor."""
+  _MOTOR_PULSE_HIGH = 1
+  _MOTOR_PULSE_LOW = 0
+
+  def __init__(self, motor_io, motor_ports, params):
+    """Initializes one Motor.
+
+    Args:
+      motor_io: A IoExpander object.
+      motor_ports: A MotorPorts for port bit offset.
+      params: A MotorParams namedtuple.
+    """
+    self._motor_io = motor_io
+    self._ports = motor_ports
+    self._params = params
+    # TODO(cychiang) See if this assumption is true, or add a mechanism
+    # to reset the motor state when init.
+    self._state = _MotorState.RELEASED
+
+  def Touch(self):
+    """Controls the motor to touch the button."""
+    if self._state == _MotorState.TOUCHED:
+      return
+
+    self._Enable(True)
+    self._SetDirection(_MotorDirection.DOWN)
+    self._Move()
+    self._Enable(False)
+
+    self._state = _MotorState.TOUCHED
+
+  def Release(self):
+    """Controls the motor to release the button."""
+    if self._state == _MotorState.RELEASED:
+      return
+
+    self._Enable(True)
+    self._SetDirection(_MotorDirection.UP)
+    self._Move()
+    self._Enable(False)
+
+    self._state = _MotorState.RELEASED
+
+  def _SetDirection(self, direction):
+    """Sets motor movement direction.
+
+    Args:
+      direction: A direction defined in _MotorDirection.
+
+    Raises:
+      MotorError: Unexpected direction.
+    """
+    # Up: set port to 0.
+    # Down: set port to 1.
+    value = None
+    if direction == _MotorDirection.UP:
+      value = 0
+    elif direction == _MotorDirection.DOWN:
+      value = 1
+    else:
+      raise MotorError('Unexpected direction: %s' % direction)
+
+    offset = self._ports.direction
+    self._motor_io.SetBit(offset, value)
+
+  def _Enable(self, enable):
+    """Enables/Disables motor.
+
+    Note this function does not start motor movement. It just turns on motor
+    or let it sleep for power saving.
+
+    Args:
+      Enable: True to enable; False to disable.
+    """
+    # TODO(cychiang) Find out whether this port works or not.
+    # Enable: set port to 0.
+    # Disable: set port to 1.
+    value = 0 if enable else 1
+    offset = self._ports.enable
+    self._motor_io.SetBit(offset, value)
+
+  def _Move(self):
+    """Moves motor by driving pulses on step port.
+
+    Drives num_pulse pulses with each pulse period_ms duration in ms.
+    """
+    offset = self._ports.step
+    half_period_sec = self._params.period_ms / 2 * 0.001
+
+    for _ in xrange(self._params.num_pulse):
+      self._motor_io.SetBit(offset, self._MOTOR_PULSE_LOW)
+      time.sleep(half_period_sec)
+      self._motor_io.SetBit(offset, self._MOTOR_PULSE_HIGH)
+      time.sleep(half_period_sec)
diff --git a/chameleond/utils/chameleon_io.py b/chameleond/utils/chameleon_io.py
index 6d5cdf9..13ab970 100644
--- a/chameleond/utils/chameleon_io.py
+++ b/chameleond/utils/chameleon_io.py
@@ -20,8 +20,9 @@
   """
 
   # TIO uses TCA6416A on 0x20, 0x21.
-  # The audio board uses TCA9995 on 0x20, 0x21, 0x22.
-  SLAVE_ADDRESSES = (0x20, 0x21, 0x22)
+  # The audio board uses TCA9555 on 0x20, 0x21, 0x22.
+  # The motor board uses TCA9555 on 0x23.
+  SLAVE_ADDRESSES = (0x20, 0x21, 0x22, 0x23)
 
   _INPUT_BASE = 0
   _OUTPUT_BASE = 2