Integrated relay controllers into ACTS

Added support for using relay controllers to interact with devices
used for testing. A device can be defined as a RelayDevice to work
nicely with the relay controllers without the test writer making low
level calls to switching on and off the relays. Different
RelayControllers can also be defined to allow different hardware
solutions ot be used to interact with the devices.

Bug: 36794752
Change-Id: I8fb095417e2d8da03dcfaf52b430d13f1d3aa74e
Fixes: 36794752
Test: acts/framework/tests/acts_relay_device_test.py
Test: ./RelayDeviceSampleTest -c <local config>
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg
index 838f4b9..8429cf6 100644
--- a/PREUPLOAD.cfg
+++ b/PREUPLOAD.cfg
@@ -13,6 +13,7 @@
 acts_host_utils_test = ./acts/framework/tests/acts_host_utils_test.py
 acts_import_test_utils_test = ./acts/framework/tests/acts_import_test_utils_test.py
 acts_import_unit_test = ./acts/framework/tests/acts_import_unit_test.py
+acts_relay_controller_test = ./acts/framework/tests/acts_relay_controller_test.py
 yapf_hook = ./tools/yapf_checker.py
 
 [Builtin Hooks]
diff --git a/acts/__init__.py b/acts/__init__.py
new file mode 100644
index 0000000..9eb92df
--- /dev/null
+++ b/acts/__init__.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
diff --git a/acts/framework/__init__.py b/acts/framework/__init__.py
new file mode 100644
index 0000000..9eb92df
--- /dev/null
+++ b/acts/framework/__init__.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
diff --git a/acts/framework/acts/controllers/relay_device_controller.py b/acts/framework/acts/controllers/relay_device_controller.py
new file mode 100644
index 0000000..36715a3
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_device_controller.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+import json
+
+from acts.controllers.relay_lib.relay_rig import RelayRig
+
+ACTS_CONTROLLER_CONFIG_NAME = "RelayDevice"
+ACTS_CONTROLLER_REFERENCE_NAME = "relay_devices"
+
+
+def create(config):
+    """Creates RelayDevice controller objects.
+
+        Args:
+            config: A dict of:
+                config_path: The path to the RelayDevice config file.
+                devices: A list of configs or names associated with the devices.
+
+        Returns:
+                A list of RelayDevice objects.
+    """
+    devices = list()
+    with open(config) as json_file:
+        relay_rig = RelayRig(json.load(json_file))
+
+    for device in relay_rig.devices.values():
+        devices.append(device)
+
+    return devices
+
+
+def destroy(relay_devices):
+    """Cleans up RelayDevice objects.
+
+        Args:
+            relay_devices: A list of AndroidDevice objects.
+    """
+    for device in relay_devices:
+        device.clean_up()
+    pass
+
+
+def get_info(relay_devices):
+    """Get information on a list of RelayDevice objects.
+
+    Args:
+        relay_devices: A list of RelayDevice objects.
+
+    Returns:
+        A list of dict, each representing info for an RelayDevice objects.
+    """
+    device_info = []
+    for device in relay_devices:
+        relay_ids = list()
+        for relay in device.relays:
+            relay_ids.append(relay)
+        info = {"name": device.name, "relays": relay_ids}
+        device_info.append(info)
+    return device_info
diff --git a/acts/framework/acts/controllers/relay_lib/__init__.py b/acts/framework/acts/controllers/relay_lib/__init__.py
new file mode 100644
index 0000000..9eb92df
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/__init__.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
diff --git a/acts/framework/acts/controllers/relay_lib/errors.py b/acts/framework/acts/controllers/relay_lib/errors.py
new file mode 100644
index 0000000..2a7ef7a
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/errors.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+from acts import signals
+
+
+class RelayConfigError(signals.ControllerError):
+    """An error found within the RelayRig config file."""
+    pass
+
+
+class RelayDeviceConnectionError(signals.ControllerError):
+    """An error for being unable to connect to the device."""
+    pass
diff --git a/acts/framework/acts/controllers/relay_lib/generic_relay_device.py b/acts/framework/acts/controllers/relay_lib/generic_relay_device.py
new file mode 100644
index 0000000..b028d96
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/generic_relay_device.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+from acts.controllers.relay_lib.relay_device import RelayDevice
+from acts.controllers.relay_lib.relay import SynchronizeRelays
+
+
+class GenericRelayDevice(RelayDevice):
+    """A default, all-encompassing implementation of RelayDevice.
+
+    This class allows for quick access to getting relay switches through the
+    subscript ([]) operator. Note that it does not allow for re-assignment or
+    additions to the relays dictionary.
+    """
+
+    def __init__(self, config, relay_rig):
+        RelayDevice.__init__(self, config, relay_rig)
+
+    def setup(self):
+        """Sets all relays to their default state (off)."""
+        with SynchronizeRelays():
+            for relay in self.relays.values():
+                relay.set_no()
+
+    def clean_up(self):
+        """Sets all relays to their default state (off)."""
+        with SynchronizeRelays():
+            for relay in self.relays.values():
+                relay.set_no()
diff --git a/acts/framework/acts/controllers/relay_lib/helpers.py b/acts/framework/acts/controllers/relay_lib/helpers.py
new file mode 100644
index 0000000..3803248
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/helpers.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+from acts.controllers.relay_lib.errors import RelayConfigError
+from six import string_types
+
+MISSING_KEY_ERR_MSG = 'key "%s" missing from %s. Offending object:\n %s'
+TYPE_MISMATCH_ERR_MSG = 'Key "%s" is of type %s. Expecting %s.' \
+                        ' Offending object:\n %s'
+
+
+def validate_key(key, dictionary, expected_type, source):
+    """Validates if a key exists and its value is the correct type.
+    Args:
+        key: The key in dictionary.
+        dictionary: The dictionary that should contain key.
+        expected_type: the type that key's value should have.
+        source: The name of the object being checked. Used for error messages.
+
+    Returns:
+        The value of dictionary[key] if no error was raised.
+
+    Raises:
+        RelayConfigError if the key does not exist, or is not of expected_type.
+    """
+    if key not in dictionary:
+        raise RelayConfigError(MISSING_KEY_ERR_MSG % (key, source, dictionary))
+    if expected_type == str:
+        if not isinstance(dictionary[key], string_types):
+            raise RelayConfigError(TYPE_MISMATCH_ERR_MSG %
+                                   (key, dictionary[key], expected_type,
+                                    dictionary))
+    elif not isinstance(dictionary[key], expected_type):
+        raise RelayConfigError(TYPE_MISMATCH_ERR_MSG %
+                               (key, dictionary[key], expected_type,
+                                dictionary))
+    return dictionary[key]
diff --git a/acts/framework/acts/controllers/relay_lib/relay.py b/acts/framework/acts/controllers/relay_lib/relay.py
new file mode 100644
index 0000000..e6f0a51
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/relay.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+from enum import Enum
+from time import sleep
+
+
+class RelayState(Enum):
+    """Enum for possible Relay States."""
+    # Pretend this means 'OFF'
+    NO = 'NORMALLY_OPEN'
+    # Pretend this means 'ON'
+    NC = 'NORMALLY_CLOSED'
+
+
+class SynchronizeRelays:
+    """A class that allows for relays to change state nearly simultaneously.
+
+    Can be used with the 'with' statement in Python:
+
+    with SynchronizeRelays():
+        relay1.set_no()
+        relay2.set_nc()
+
+    Note that the thread will still wait for RELAY_TRANSITION_WAIT_TIME
+    after execution leaves the 'with' statement.
+    """
+    _sync_sleep_flag = False
+
+    def __enter__(self):
+        self.prev_toggle_time = Relay.transition_wait_time
+        self.prev_sync_flag = SynchronizeRelays._sync_sleep_flag
+        Relay.transition_wait_time = 0
+        SynchronizeRelays._sync_sleep_flag = False
+
+    def __exit__(self, type, value, traceback):
+        if SynchronizeRelays._sync_sleep_flag:
+            sleep(Relay.transition_wait_time)
+
+        Relay.transition_wait_time = self.prev_toggle_time
+        SynchronizeRelays._sync_sleep_flag = self.prev_sync_flag
+
+
+class Relay(object):
+    """A class representing a single relay switch on a RelayBoard.
+
+    References to these relays are stored in both the RelayBoard and the
+    RelayDevice classes under the variable "relays". GenericRelayDevice can also
+    access these relays through the subscript ([]) operator.
+
+    At the moment, relays only have a valid state of 'ON' or 'OFF'. This may be
+    extended in a subclass if needed. Keep in mind that if this is done, changes
+    will also need to be made in the RelayRigParser class to initialize the
+    relays.
+
+    """
+    """How long to wait for relays to transition state."""
+    transition_wait_time = .2
+
+    def __init__(self, relay_board, position):
+        self.relay_board = relay_board
+        self.position = position
+        self._original_state = relay_board.get_relay_status(self.position)
+        self.relay_id = "{}/{}".format(self.relay_board.name, self.position)
+
+    def set_no(self):
+        """Sets the relay to the 'NO' state. Shorthand for set(RelayState.NO).
+
+        Blocks the thread for Relay.transition_wait_time.
+        """
+        self.set(RelayState.NO)
+
+    def set_nc(self):
+        """Sets the relay to the 'NC' state. Shorthand for set(RelayState.NC).
+
+        Blocks the thread for Relay.transition_wait_time.
+
+        """
+        self.set(RelayState.NC)
+
+    def toggle(self):
+        """Swaps the state from 'NO' to 'NC' or 'NC' to 'NO'.
+        Blocks the thread for Relay.transition_wait_time.
+        """
+        if self.get_status() == RelayState.NO:
+            self.set(RelayState.NC)
+        else:
+            self.set(RelayState.NO)
+
+    def set(self, state):
+        """Sets the relay to the 'NO' or 'NC' state.
+
+        Blocks the thread for Relay.transition_wait_time.
+
+        Args:
+            state: either 'NO' or 'NC'.
+
+        Raises:
+            ValueError if state is not 'NO' or 'NC'.
+
+        """
+        if state is not RelayState.NO and state is not RelayState.NC:
+            raise ValueError(
+                'Invalid state. Received "%s". Expected any of %s.' %
+                (state, [state for state in RelayState]))
+        if self.get_status() != state:
+            self.relay_board.set(self.position, state)
+            SynchronizeRelays._sync_sleep_flag = True
+            sleep(Relay.transition_wait_time)
+
+    def set_no_for(self, seconds=.25):
+        """Sets the relay to 'NORMALLY_OPEN' for seconds. Blocks the thread.
+
+        Args:
+            seconds: The number of seconds to sleep for.
+        """
+        self.set_no()
+        sleep(seconds)
+        self.set_nc()
+
+    def set_nc_for(self, seconds=.25):
+        """Sets the relay to 'NORMALLY_CLOSED' for seconds. Blocks the thread.
+
+        Respects Relay.transition_wait_time for toggling state.
+
+        Args:
+            seconds: The number of seconds to sleep for.
+        """
+        self.set_nc()
+        sleep(seconds)
+        self.set_no()
+
+    def get_status(self):
+        return self.relay_board.get_relay_status(self.position)
+
+    def clean_up(self):
+        """Does any clean up needed to allow the next series of tests to run.
+
+        For now, all this does is switches to its previous state. Inheriting
+        from this class and overriding this method would be the best course of
+        action to allow a more complex clean up to occur. If you do this, be
+        sure to make the necessary modifications in RelayRig.initialize_relay
+        and RelayRigParser.parse_json_relays.
+        """
+
+        self.set(self._original_state)
diff --git a/acts/framework/acts/controllers/relay_lib/relay_board.py b/acts/framework/acts/controllers/relay_lib/relay_board.py
new file mode 100644
index 0000000..c59e20d
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/relay_board.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+from acts.controllers.relay_lib.errors import RelayConfigError
+from acts.controllers.relay_lib.helpers import validate_key
+from acts.controllers.relay_lib.relay import Relay
+from acts.controllers.relay_lib.relay import RelayState
+
+
+class RelayBoard(object):
+    """Handles interfacing with the Relays and RelayDevices.
+
+    This is the base class for all RelayBoards.
+    """
+
+    def __init__(self, config):
+        """Creates a RelayBoard instance. Handles naming and relay creation.
+
+        Args:
+            config: A configuration dictionary, usually pulled from an element
+            under in "boards" list in the relay rig config file.
+        """
+        self.name = validate_key('name', config, str, 'config')
+        if '/' in self.name:
+            raise RelayConfigError('RelayBoard name cannot contain a "/".')
+        self.relays = dict()
+        for pos in self.get_relay_position_list():
+            self.relays[pos] = Relay(self, pos)
+
+    def set(self, relay_position, state):
+        """Sets the relay to the given state.
+
+        Args:
+            relay_position: the relay having its state modified.
+            state: the state to set the relay to. Currently only states NO and
+                   NC are supported.
+        """
+        raise NotImplementedError()
+
+    def get_relay_position_list(self):
+        """Returns a list of all possible relay positions."""
+        raise NotImplementedError()
+
+    def get_relay_status(self, relay):
+        """Returns the state of the given relay."""
+        raise NotImplementedError()
diff --git a/acts/framework/acts/controllers/relay_lib/relay_device.py b/acts/framework/acts/controllers/relay_lib/relay_device.py
new file mode 100644
index 0000000..31ff862
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/relay_device.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+from acts.controllers.relay_lib.errors import RelayConfigError
+from acts.controllers.relay_lib.helpers import validate_key
+
+
+class RelayDevice(object):
+    """The base class for all relay devices.
+
+    RelayDevice has access to both its relays as well as the relay rig it is
+    a part of. Note that you can receive references to the relay_boards
+    through relays[0...n].board. The relays are not guaranteed to be on
+    the same relay board.
+    """
+
+    def __init__(self, config, relay_rig):
+        """Creates a RelayDevice.
+
+        Args:
+            config: The dictionary found in the config file for this device.
+            You can add your own params to the config file if needed, and they
+            will be found in this dictionary.
+            relay_rig: The RelayRig the device is attached to. This won't be
+            useful for classes that inherit from RelayDevice, so just pass it
+            down to this __init__.
+        """
+        self.rig = relay_rig
+        self.relays = dict()
+
+        validate_key('name', config, str, '"devices" element')
+        self.name = config['name']
+
+        validate_key('relays', config, list, '"devices list element')
+        if len(config['relays']) < 1:
+            raise RelayConfigError(
+                'Key "relays" must have at least 1 element.')
+
+        for relay_config in config['relays']:
+            if isinstance(relay_config, dict):
+                name = validate_key('name', relay_config, str,
+                                    '"relays" element in "devices"')
+                if 'pos' in relay_config:
+                    self.relays[name] = relay_rig.relays[relay_config['pos']]
+                else:
+                    validate_key('pos', relay_config, int,
+                                 '"relays" element in "devices"')
+            else:
+                raise TypeError('Key "relay" is of type {}. Expecting {}. '
+                                'Offending object:\n {}'.format(
+                                    type(relay_config['relay']), dict,
+                                    relay_config))
+
+    def setup(self):
+        """Sets up the relay device to be ready for commands."""
+        pass
+
+    def clean_up(self):
+        """Sets the relay device back to its inert state."""
+        pass
diff --git a/acts/framework/acts/controllers/relay_lib/relay_rig.py b/acts/framework/acts/controllers/relay_lib/relay_rig.py
new file mode 100644
index 0000000..c959a50
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/relay_rig.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+from acts.controllers.relay_lib.errors import RelayConfigError
+from acts.controllers.relay_lib.helpers import validate_key
+from acts.controllers.relay_lib.sain_smart_board import SainSmartBoard
+from acts.controllers.relay_lib.generic_relay_device import GenericRelayDevice
+
+
+class RelayRig:
+    """A group of relay boards and their connected devices.
+
+    This class is also responsible for handling the creation of the relay switch
+    boards, as well as the devices and relays associated with them.
+
+    The boards dict can contain different types of relay boards. They share a
+    common interface through inheriting from RelayBoard. This layer can be
+    ignored by the user.
+
+    The relay devices are stored in a dict of (device_name: device). These
+    device references should be used by the user when they want to directly
+    interface with the relay switches. See RelayDevice or GeneralRelayDevice for
+    implementation.
+
+    """
+    DUPLICATE_ID_ERR_MSG = 'The {} "{}" is not unique. Duplicated in:\n {}'
+
+    # A dict of lambdas that instantiate relay board upon invocation.
+    # The key is the class type name, the value is the lambda.
+    _board_constructors = {
+        'SainSmartBoard': lambda x: SainSmartBoard(x),
+    }
+
+    # Similar to the dict above, except for devices.
+    _device_constructors = {
+        'GenericRelayDevice': lambda x, rig: GenericRelayDevice(x, rig),
+    }
+
+    def __init__(self, config):
+        self.relays = dict()
+        self.boards = dict()
+        self.devices = dict()
+
+        validate_key('boards', config, list, 'relay config file')
+
+        for elem in config['boards']:
+            board = self.create_relay_board(elem)
+            if board.name in self.boards:
+                raise RelayConfigError(
+                    self.DUPLICATE_ID_ERR_MSG.format('name', elem['name'],
+                                                     elem))
+            self.boards[board.name] = board
+
+        # Note: 'boards' is a necessary value, 'devices' is not.
+        if 'devices' in config:
+            for elem in config['devices']:
+                relay_device = self.create_relay_device(elem)
+                if relay_device.name in self.devices:
+                    raise RelayConfigError(
+                        self.DUPLICATE_ID_ERR_MSG.format(
+                            'name', elem['name'], elem))
+                self.devices[relay_device.name] = relay_device
+        else:
+            device_config = dict()
+            device_config['name'] = 'GenericRelayDevice'
+            device_config['relays'] = list()
+            for relay_id in self.relays:
+                device_config['relays'].append({
+                    'name': str(relay_id),
+                    'pos': relay_id
+                })
+            self.devices['device'] = (self.create_relay_device(device_config))
+
+    def create_relay_board(self, config):
+        """Builds a RelayBoard from the given config.
+
+        Args:
+            config: An object containing 'type', 'name', 'relays', and
+            (optionally) 'properties'. See the example json file.
+
+        Returns:
+            A RelayBoard with the given type found in the config.
+
+        Raises:
+            RelayConfigError if config['type'] doesn't exist or is not a string.
+
+        """
+        validate_key('type', config, str, '"boards" element')
+        try:
+            ret = self._board_constructors[config['type']](config)
+        except LookupError:
+            raise RelayConfigError(
+                'RelayBoard with type {} not found. Has it been added '
+                'to the _board_constructors dict?'.format(config['type']))
+        for _, relay in ret.relays.items():
+            self.relays[relay.relay_id] = relay
+        return ret
+
+    def create_relay_device(self, config):
+        """Builds a RelayDevice from the given config.
+
+        When given no 'type' key in the config, the function will default to
+        returning a GenericRelayDevice with the relays found in the 'relays'
+        array.
+
+        Args:
+            config: An object containing 'name', 'relays', and (optionally)
+            type.
+
+        Returns:
+            A RelayDevice with the given type found in the config. If no type is
+            found, it will default to GenericRelayDevice.
+
+        Raises:
+            RelayConfigError if the type given does not match any from the
+            _device_constructors dictionary.
+
+        """
+        if 'type' in config:
+            if config['type'] not in RelayRig._device_constructors:
+                raise RelayConfigError(
+                    'Device with type {} not found. Has it been added '
+                    'to the _device_constructors dict?'.format(config['type']))
+            else:
+                device = self._device_constructors[config['type']](config,
+                                                                   self)
+
+        else:
+            device = GenericRelayDevice(config, self)
+
+        return device
diff --git a/acts/framework/acts/controllers/relay_lib/sain_smart_board.py b/acts/framework/acts/controllers/relay_lib/sain_smart_board.py
new file mode 100644
index 0000000..0b273a6
--- /dev/null
+++ b/acts/framework/acts/controllers/relay_lib/sain_smart_board.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+from future.moves.urllib.request import urlopen
+import re
+
+from acts.controllers.relay_lib.errors import RelayDeviceConnectionError
+from acts.controllers.relay_lib.helpers import validate_key
+from acts.controllers.relay_lib.relay import RelayState
+from acts.controllers.relay_lib.relay_board import RelayBoard
+
+BASE_URL = 'http://192.168.1.4/30000/'
+
+
+class SainSmartBoard(RelayBoard):
+    """Controls and queries SainSmart Web Relay Board.
+
+    Controls and queries SainSmart Web Relay Board, found here:
+    http://www.sainsmart.com/sainsmart-rj45-tcp-ip-remote-controller-board-with-8-channels-relay-integrated.html
+    this uses a web interface to toggle relays.
+
+    There is an unmentioned hidden status page that can be found at <root>/99/.
+    """
+
+    # No longer used. Here for debugging purposes.
+    #
+    # Old status pages. Used before base_url/99 was found.
+    # STATUS_1 = '40'
+    # STATUS_2 = '43'
+    #
+    # This is the regex used to parse the old status pages:
+    # r'y-\d(?P<relay>\d).+?> (?:&nbsp)?(?P<status>.*?)&'
+    #
+    # Pages that will turn all switches on or off, even the ghost switches.
+    # ALL_RELAY_OFF = '44'
+    # ALL_RELAY_ON = '45'
+
+    HIDDEN_STATUS_PAGE = '99'
+
+    VALID_RELAY_POSITIONS = [0, 1, 2, 3, 4, 5, 6, 7]
+    NUM_RELAYS = 8
+
+    def __init__(self, config):
+        # This will be lazy loaded
+        self.status_dict = None
+        self.base_url = validate_key('base_url', config, str, 'config')
+        if not self.base_url.endswith('/'):
+            self.base_url += '/'
+        RelayBoard.__init__(self, config)
+
+    def get_relay_position_list(self):
+        return self.VALID_RELAY_POSITIONS
+
+    def _load_page(self, relative_url):
+        """Loads a web page at self.base_url + relative_url.
+
+        Properly opens and closes the web page.
+
+        Args:
+            relative_url: The string appended to the base_url
+
+        Returns:
+            the contents of the web page.
+
+        Raises:
+            A RelayDeviceConnectionError is raised if the page cannot be loaded.
+
+        """
+        try:
+            page = urlopen(self.base_url + relative_url)
+            result = page.read().decode("utf-8")
+            page.close()
+        except:
+            raise RelayDeviceConnectionError(
+                'Unable to connect to board "{}" through {}'.format(
+                    self.name, self.base_url + relative_url))
+        return result
+
+    def _sync_status_dict(self):
+        """Returns a dictionary of relays and there current state."""
+        result = self._load_page(self.HIDDEN_STATUS_PAGE)
+        status_string = re.search(r'">([01]*)TUX', result).group(1)
+
+        self.status_dict = dict()
+        for index, char in enumerate(status_string):
+            self.status_dict[index] = \
+                RelayState.NC if char == '1' else RelayState.NO
+
+    def _print_status(self):
+        """Prints out the list of relays and their current state."""
+        for i in range(0, 8):
+            print('Relay {}: {}'.format(i, self.status_dict[i]))
+
+    def get_relay_status(self, relay_position):
+        """Returns the current status of the passed in relay."""
+        if self.status_dict is None:
+            self._sync_status_dict()
+        return self.status_dict[relay_position]
+
+    def set(self, relay_position, value):
+        """Sets the given relay to be either ON or OFF, indicated by value."""
+        if self.status_dict is None:
+            self._sync_status_dict()
+        self._load_page(self._get_relay_url_code(relay_position, value))
+        self.status_dict[relay_position] = value
+
+    @staticmethod
+    def _get_relay_url_code(relay_position, no_or_nc):
+        """Returns the two digit code corresponding to setting the relay."""
+        if no_or_nc == RelayState.NC:
+            on_modifier = 1
+        else:
+            on_modifier = 0
+        return '{:02d}'.format(relay_position * 2 + on_modifier)
diff --git a/acts/framework/acts/keys.py b/acts/framework/acts/keys.py
index 9c53fd6..ccd5e2e 100644
--- a/acts/framework/acts/keys.py
+++ b/acts/framework/acts/keys.py
@@ -36,6 +36,7 @@
     # Config names for controllers packaged in ACTS.
     key_android_device = "AndroidDevice"
     key_native_android_device = "NativeAndroidDevice"
+    key_relay_device = "RelayDevice"
     key_access_point = "AccessPoint"
     key_attenuator = "Attenuator"
     key_iperf_server = "IPerfServer"
@@ -51,6 +52,7 @@
     m_key_monsoon = "monsoon"
     m_key_android_device = "android_device"
     m_key_native_android_device = "native_android_device"
+    m_key_relay_device = "relay_device_controller"
     m_key_access_point = "access_point"
     m_key_attenuator = "attenuator"
     m_key_iperf_server = "iperf_server"
@@ -62,8 +64,9 @@
 
     # Controller names packaged with ACTS.
     builtin_controller_names = [
-        key_android_device, key_native_android_device, key_access_point,
-        key_attenuator, key_iperf_server, key_monsoon, key_sniffer
+        key_android_device, key_native_android_device, key_relay_device,
+        key_access_point, key_attenuator, key_iperf_server, key_monsoon,
+        key_sniffer
     ]
 
 
diff --git a/acts/framework/tests/acts_relay_controller_test.py b/acts/framework/tests/acts_relay_controller_test.py
new file mode 100755
index 0000000..f16d3f2
--- /dev/null
+++ b/acts/framework/tests/acts_relay_controller_test.py
@@ -0,0 +1,639 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+import copy
+import os
+import tempfile
+import shutil
+import unittest
+
+import acts.controllers.relay_lib.errors as errors
+from acts.controllers.relay_lib.relay import Relay
+from acts.controllers.relay_lib.relay import RelayState
+from acts.controllers.relay_lib.relay import SynchronizeRelays
+from acts.controllers.relay_lib.relay_board import RelayBoard
+from acts.controllers.relay_lib.sain_smart_board import SainSmartBoard
+from acts.controllers.relay_lib.relay_rig import RelayRig
+from acts.controllers.relay_lib.generic_relay_device import GenericRelayDevice
+from acts.controllers.relay_lib.relay_device import RelayDevice
+
+
+class MockBoard(RelayBoard):
+    def __init__(self, config):
+        self.relay_states = dict()
+        self.relay_previous_states = dict()
+        RelayBoard.__init__(self, config)
+
+    def get_relay_position_list(self):
+        return [0, 1]
+
+    def get_relay_status(self, relay_position):
+        if relay_position not in self.relay_states:
+            self.relay_states[relay_position] = RelayState.NO
+            self.relay_previous_states[relay_position] = RelayState.NO
+        return self.relay_states[relay_position]
+
+    def set(self, relay_position, state):
+        self.relay_previous_states[relay_position] = self.get_relay_status(
+            relay_position)
+        self.relay_states[relay_position] = state
+        return state
+
+
+class ActsRelayTest(unittest.TestCase):
+    def setUp(self):
+        Relay.transition_wait_time = 0
+        self.config = {
+            'name': 'MockBoard',
+            'relays': [{
+                'name': 'Relay',
+                'relay_pos': 0
+            }]
+        }
+        self.board = MockBoard(self.config)
+        self.relay = Relay(self.board, 'Relay')
+        self.board.set(self.relay.position, RelayState.NO)
+
+    def test_turn_on_from_off(self):
+        self.board.set(self.relay.position, RelayState.NO)
+        self.relay.set_nc()
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NC)
+
+    def test_turn_on_from_on(self):
+        self.board.set(self.relay.position, RelayState.NC)
+        self.relay.set_nc()
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NC)
+
+    def test_turn_off_from_on(self):
+        self.board.set(self.relay.position, RelayState.NC)
+        self.relay.set_no()
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NO)
+
+    def test_turn_off_from_off(self):
+        self.board.set(self.relay.position, RelayState.NO)
+        self.relay.set_no()
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NO)
+
+    def test_toggle_off_to_on(self):
+        self.board.set(self.relay.position, RelayState.NO)
+        self.relay.toggle()
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NC)
+
+    def test_toggle_on_to_off(self):
+        self.board.set(self.relay.position, RelayState.NC)
+        self.relay.toggle()
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NO)
+
+    def test_set_on(self):
+        self.board.set(self.relay.position, RelayState.NO)
+        self.relay.set(RelayState.NC)
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NC)
+
+    def test_set_off(self):
+        self.board.set(self.relay.position, RelayState.NC)
+        self.relay.set(RelayState.NO)
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NO)
+
+    def test_set_foo(self):
+        with self.assertRaises(ValueError):
+            self.relay.set('FOO')
+
+    def test_set_nc_for(self):
+        # Here we set twice so relay_previous_state will also be OFF
+        self.board.set(self.relay.position, RelayState.NO)
+        self.board.set(self.relay.position, RelayState.NO)
+
+        self.relay.set_nc_for(0)
+
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NO)
+        self.assertEqual(self.board.relay_previous_states[self.relay.position],
+                         RelayState.NC)
+
+    def test_set_no_for(self):
+        # Here we set twice so relay_previous_state will also be OFF
+        self.board.set(self.relay.position, RelayState.NC)
+        self.board.set(self.relay.position, RelayState.NC)
+
+        self.relay.set_no_for(0)
+
+        self.assertEqual(
+            self.board.get_relay_status(self.relay.position), RelayState.NC)
+        self.assertEqual(self.board.relay_previous_states[self.relay.position],
+                         RelayState.NO)
+
+    def test_get_status_on(self):
+        self.board.set(self.relay.position, RelayState.NC)
+        self.assertEqual(self.relay.get_status(), RelayState.NC)
+
+    def test_get_status_off(self):
+        self.board.set(self.relay.position, RelayState.NO)
+        self.assertEqual(self.relay.get_status(), RelayState.NO)
+
+    def test_clean_up_default_on(self):
+        new_relay = Relay(self.board, 0)
+        self.board.set(new_relay.position, RelayState.NO)
+        new_relay.clean_up()
+
+        self.assertEqual(
+            self.board.get_relay_status(new_relay.position), RelayState.NO)
+
+    def test_clean_up_default_off(self):
+        new_relay = Relay(self.board, 0)
+        self.board.set(new_relay.position, RelayState.NC)
+        new_relay.clean_up()
+
+        self.assertEqual(
+            self.board.get_relay_status(new_relay.position), RelayState.NO)
+
+
+class ActsSainSmartBoardTest(unittest.TestCase):
+    STATUS_MSG = ('<small><a href="{}"></a>'
+                  '</small><a href="{}/{}TUX">{}TUX</a><p>')
+
+    RELAY_ON_PAGE_CONTENTS = 'relay_on page'
+    RELAY_OFF_PAGE_CONTENTS = 'relay_off page'
+
+    def setUp(self):
+        Relay.transition_wait_time = 0
+        self.test_dir = 'file://' + tempfile.mkdtemp() + '/'
+
+        # Creates the files used for testing
+        self._set_status_page('0000000000000000')
+        with open(self.test_dir[7:] + '00', 'w+') as file:
+            file.write(self.RELAY_OFF_PAGE_CONTENTS)
+        with open(self.test_dir[7:] + '01', 'w+') as file:
+            file.write(self.RELAY_ON_PAGE_CONTENTS)
+
+        self.config = ({
+            'name':
+            'SSBoard',
+            'base_url':
+            self.test_dir,
+            'relays': [{
+                'name': '0',
+                'relay_pos': 0
+            }, {
+                'name': '1',
+                'relay_pos': 1
+            }, {
+                'name': '2',
+                'relay_pos': 7
+            }]
+        })
+        self.ss_board = SainSmartBoard(self.config)
+        self.r0 = Relay(self.ss_board, 0)
+        self.r1 = Relay(self.ss_board, 1)
+        self.r7 = Relay(self.ss_board, 7)
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir[7:])
+
+    def test_get_url_code(self):
+        result = self.ss_board._get_relay_url_code(self.r0.position,
+                                                   RelayState.NO)
+        self.assertEqual(result, '00')
+
+        result = self.ss_board._get_relay_url_code(self.r0.position,
+                                                   RelayState.NC)
+        self.assertEqual(result, '01')
+
+        result = self.ss_board._get_relay_url_code(self.r7.position,
+                                                   RelayState.NO)
+        self.assertEqual(result, '14')
+
+        result = self.ss_board._get_relay_url_code(self.r7.position,
+                                                   RelayState.NC)
+        self.assertEqual(result, '15')
+
+    def test_load_page_status(self):
+        self._set_status_page('0000111100001111')
+        result = self.ss_board._load_page(SainSmartBoard.HIDDEN_STATUS_PAGE)
+        self.assertTrue(
+            result.endswith(
+                '0000111100001111TUX">0000111100001111TUX</a><p>'))
+
+    def test_load_page_relay(self):
+        result = self.ss_board._load_page('00')
+        self.assertEqual(result, self.RELAY_OFF_PAGE_CONTENTS)
+
+        result = self.ss_board._load_page('01')
+        self.assertEqual(result, self.RELAY_ON_PAGE_CONTENTS)
+
+    def test_load_page_no_connection(self):
+        with self.assertRaises(errors.RelayDeviceConnectionError):
+            self.ss_board._load_page('**')
+
+    def _set_status_page(self, status_16_chars):
+        with open(self.test_dir[7:] + '99', 'w+') as status_file:
+            status_file.write(
+                self.STATUS_MSG.format(self.test_dir[:-1], self.test_dir[:-1],
+                                       status_16_chars, status_16_chars))
+
+    def _test_sync_status_dict(self, status_16_chars):
+        self._set_status_page(status_16_chars)
+        expected_dict = dict()
+
+        for index, char in enumerate(status_16_chars):
+            expected_dict[
+                index] = RelayState.NC if char == '1' else RelayState.NO
+
+        self.ss_board._sync_status_dict()
+        self.assertDictEqual(expected_dict, self.ss_board.status_dict)
+
+    def test_sync_status_dict(self):
+        self._test_sync_status_dict('0000111100001111')
+        self._test_sync_status_dict('0000000000000000')
+        self._test_sync_status_dict('0101010101010101')
+        self._test_sync_status_dict('1010101010101010')
+        self._test_sync_status_dict('1111111111111111')
+
+    def test_get_relay_status_status_dict_none(self):
+        self._set_status_page('1111111111111111')
+        self.ss_board.status_dict = None
+        self.assertEqual(
+            self.ss_board.get_relay_status(self.r0.position), RelayState.NC)
+
+    def test_get_relay_status_status_dict_on(self):
+        self.r0.set(RelayState.NC)
+        self.assertEqual(
+            self.ss_board.get_relay_status(self.r0.position), RelayState.NC)
+
+    def test_get_relay_status_status_dict_off(self):
+        self.r0.set(RelayState.NO)
+        self.assertEqual(
+            self.ss_board.get_relay_status(self.r0.position), RelayState.NO)
+
+    def test_set_on(self):
+        os.utime(self.test_dir[7:] + '01', (0, 0))
+        self.ss_board.set(self.r0.position, RelayState.NC)
+        self.assertNotEqual(os.stat(self.test_dir[7:] + '01').st_atime, 0)
+
+    def test_set_off(self):
+        os.utime(self.test_dir[7:] + '00', (0, 0))
+        self.ss_board.set(self.r0.position, RelayState.NO)
+        self.assertNotEqual(os.stat(self.test_dir[7:] + '00').st_atime, 0)
+
+
+class ActsRelayRigTest(unittest.TestCase):
+    def setUp(self):
+        Relay.transition_wait_time = 0
+        self.config = {
+            'boards': [{
+                'type': 'SainSmartBoard',
+                'name': 'ss_control',
+                'base_url': 'http://192.168.1.4/30000/'
+            }, {
+                'type': 'SainSmartBoard',
+                'name': 'ss_control_2',
+                'base_url': 'http://192.168.1.4/30000/'
+            }],
+            'devices': [{
+                'type':
+                'GenericRelayDevice',
+                'name':
+                'device',
+                'relays': [{
+                    'name': 'Relay00',
+                    'pos': 'ss_control/0'
+                }, {
+                    'name': 'Relay10',
+                    'pos': 'ss_control/1'
+                }]
+            }]
+        }
+
+    def test_init_relay_rig_missing_boards(self):
+        flawed_config = copy.deepcopy(self.config)
+        del flawed_config['boards']
+        with self.assertRaises(errors.RelayConfigError):
+            RelayRig(flawed_config)
+
+    def test_init_relay_rig_is_not_list(self):
+        flawed_config = copy.deepcopy(self.config)
+        flawed_config['boards'] = self.config['boards'][0]
+        with self.assertRaises(errors.RelayConfigError):
+            RelayRig(flawed_config)
+
+    def test_init_relay_rig_duplicate_board_names(self):
+        flawed_config = copy.deepcopy(self.config)
+        flawed_config['boards'][1]['name'] = (self.config['boards'][0]['name'])
+        with self.assertRaises(errors.RelayConfigError):
+            RelayRigMock(flawed_config)
+
+    def test_init_relay_rig_device_gets_relays(self):
+        modded_config = copy.deepcopy(self.config)
+        modded_config['devices'][0]['relays'] = [
+            modded_config['devices'][0]['relays'][0]
+        ]
+        relay_rig = RelayRigMock(modded_config)
+        self.assertEqual(len(relay_rig.relays), 4)
+        self.assertEqual(len(relay_rig.devices['device'].relays), 1)
+
+        relay_rig = RelayRigMock(self.config)
+        self.assertEqual(len(relay_rig.devices['device'].relays), 2)
+
+    def test_init_relay_rig_correct_device_type(self):
+        relay_rig = RelayRigMock(self.config)
+        self.assertEqual(len(relay_rig.devices), 1)
+        self.assertIsInstance(relay_rig.devices['device'], GenericRelayDevice)
+
+    def test_init_relay_rig_missing_devices_creates_generic_device(self):
+        modded_config = copy.deepcopy(self.config)
+        del modded_config['devices']
+        relay_rig = RelayRigMock(modded_config)
+        self.assertEqual(len(relay_rig.devices), 1)
+        self.assertIsInstance(relay_rig.devices['device'], GenericRelayDevice)
+        self.assertDictEqual(relay_rig.devices['device'].relays,
+                             relay_rig.relays)
+
+
+class RelayRigMock(RelayRig):
+    """A RelayRig that substitutes the MockBoard for any board."""
+
+    _board_constructors = {
+        'SainSmartBoard': lambda x: MockBoard(x),
+    }
+
+    def __init__(self, config=None):
+        if not config:
+            config = {
+                "boards": [{
+                    'name': 'MockBoard',
+                    'type': 'SainSmartBoard'
+                }]
+            }
+
+        RelayRig.__init__(self, config)
+
+
+class ActsGenericRelayDeviceTest(unittest.TestCase):
+    def setUp(self):
+        Relay.transition_wait_time = 0
+        self.board_config = {'name': 'MockBoard', 'type': 'SainSmartBoard'}
+
+        self.board = MockBoard(self.board_config)
+        self.r0 = self.board.relays[0]
+        self.r1 = self.board.relays[1]
+
+        self.device_config = {
+            'name':
+            'MockDevice',
+            'relays': [{
+                'name': 'r0',
+                'pos': 'MockBoard/0'
+            }, {
+                'name': 'r1',
+                'pos': 'MockBoard/1'
+            }]
+        }
+        config = {
+            'boards': [self.board_config],
+            'devices': [self.device_config]
+        }
+        self.rig = RelayRigMock(config)
+        self.rig.boards['MockBoard'] = self.board
+        self.rig.relays[self.r0.relay_id] = self.r0
+        self.rig.relays[self.r1.relay_id] = self.r1
+
+    def test_setup_single_relay(self):
+        self.r0.set(RelayState.NC)
+        self.r1.set(RelayState.NC)
+
+        modified_config = copy.deepcopy(self.device_config)
+        del modified_config['relays'][1]
+
+        generic_relay_device = GenericRelayDevice(modified_config, self.rig)
+        generic_relay_device.setup()
+
+        self.assertEqual(self.r0.get_status(), RelayState.NO)
+        self.assertEqual(self.r1.get_status(), RelayState.NC)
+
+    def test_setup_multiple_relays(self):
+        self.board.set(self.r0.position, RelayState.NC)
+        self.board.set(self.r1.position, RelayState.NC)
+
+        generic_relay_device = GenericRelayDevice(self.device_config, self.rig)
+        generic_relay_device.setup()
+
+        self.assertEqual(self.r0.get_status(), RelayState.NO)
+        self.assertEqual(self.r1.get_status(), RelayState.NO)
+
+    def test_cleanup_single_relay(self):
+        self.test_setup_single_relay()
+
+    def test_cleanup_multiple_relays(self):
+        self.test_setup_multiple_relays()
+
+
+class ActsRelayDeviceTest(unittest.TestCase):
+    def setUp(self):
+        Relay.transition_wait_time = 0
+
+        self.board_config = {
+            'name': 'MockBoard',
+            'relays': [{
+                'id': 0,
+                'relay_pos': 0
+            }, {
+                'id': 1,
+                'relay_pos': 1
+            }]
+        }
+
+        self.board = MockBoard(self.board_config)
+        self.r0 = Relay(self.board, 0)
+        self.r1 = Relay(self.board, 1)
+        self.board.set(self.r0.position, RelayState.NO)
+        self.board.set(self.r1.position, RelayState.NO)
+
+        self.rig = RelayRigMock()
+        self.rig.boards['MockBoard'] = self.board
+        self.rig.relays[self.r0.relay_id] = self.r0
+        self.rig.relays[self.r1.relay_id] = self.r1
+
+        self.device_config = {
+            "type":
+            "GenericRelayDevice",
+            "name":
+            "device",
+            "relays": [{
+                'name': 'r0',
+                'pos': 'MockBoard/0'
+            }, {
+                'name': 'r1',
+                'pos': 'MockBoard/1'
+            }]
+        }
+
+    def test_init_raise_on_name_missing(self):
+        flawed_config = copy.deepcopy(self.device_config)
+        del flawed_config['name']
+        with self.assertRaises(errors.RelayConfigError):
+            RelayDevice(flawed_config, self.rig)
+
+    def test_init_raise_on_name_wrong_type(self):
+        flawed_config = copy.deepcopy(self.device_config)
+        flawed_config['name'] = {}
+        with self.assertRaises(errors.RelayConfigError):
+            RelayDevice(flawed_config, self.rig)
+
+    def test_init_raise_on_relays_missing(self):
+        flawed_config = copy.deepcopy(self.device_config)
+        del flawed_config['relays']
+        with self.assertRaises(errors.RelayConfigError):
+            RelayDevice(flawed_config, self.rig)
+
+    def test_init_raise_on_relays_wrong_type(self):
+        flawed_config = copy.deepcopy(self.device_config)
+        flawed_config['relays'] = str
+        with self.assertRaises(errors.RelayConfigError):
+            RelayDevice(flawed_config, self.rig)
+
+    def test_init_raise_on_relays_is_empty(self):
+        flawed_config = copy.deepcopy(self.device_config)
+        flawed_config['relays'] = []
+        with self.assertRaises(errors.RelayConfigError):
+            RelayDevice(flawed_config, self.rig)
+
+    def test_init_raise_on_relays_are_dicts_without_names(self):
+        flawed_config = copy.deepcopy(self.device_config)
+        flawed_config['relays'] = [{'id': 0}, {'id': 1}]
+        with self.assertRaises(errors.RelayConfigError):
+            RelayDevice(flawed_config, self.rig)
+
+    def test_init_raise_on_relays_are_dicts_without_ids(self):
+        flawed_config = copy.deepcopy(self.device_config)
+        flawed_config['relays'] = [{'name': 'r0'}, {'name': 'r1'}]
+        with self.assertRaises(errors.RelayConfigError):
+            RelayDevice(flawed_config, self.rig)
+
+    def test_init_pass_relays_have_ids_and_names(self):
+        RelayDevice(self.device_config, self.rig)
+
+
+class TestRelayRigParser(unittest.TestCase):
+    def setUp(self):
+        Relay.transition_wait_time = 0
+        self.board_config = {
+            'name': 'MockBoard',
+            'relays': [{
+                'id': 'r0',
+                'relay_pos': 0
+            }, {
+                'id': 'r1',
+                'relay_pos': 1
+            }]
+        }
+        self.r0 = self.board_config['relays'][0]
+        self.r1 = self.board_config['relays'][1]
+        self.board = MockBoard(self.board_config)
+
+    def test_create_relay_board_raise_on_missing_type(self):
+        with self.assertRaises(errors.RelayConfigError):
+            RelayRigMock().create_relay_board(self.board_config)
+
+    def test_create_relay_board_valid_config(self):
+        config = copy.deepcopy(self.board_config)
+        config['type'] = 'SainSmartBoard'
+        # Note: we use a raise here because SainSmartBoard requires us to
+        # connect to the status page upon creation. SainSmartBoard is the
+        # only valid board at the moment.
+        RelayRigMock().create_relay_board(config)
+
+    def test_create_relay_board_raise_on_type_not_found(self):
+        flawed_config = copy.deepcopy(self.board_config)
+        flawed_config['type'] = 'NonExistentBoard'
+        with self.assertRaises(errors.RelayConfigError):
+            RelayRigMock().create_relay_board(flawed_config)
+
+    def test_create_relay_device_create_generic_on_missing_type(self):
+        rig = RelayRigMock()
+        rig.relays['r0'] = self.r0
+        rig.relays['r1'] = self.r1
+        config = {
+            'name':
+            'name',
+            'relays': [{
+                'name': 'r0',
+                'pos': 'MockBoard/0'
+            }, {
+                'name': 'r1',
+                'pos': 'MockBoard/1'
+            }]
+        }
+        relay_device = rig.create_relay_device(config)
+        self.assertIsInstance(relay_device, GenericRelayDevice)
+
+    def test_create_relay_device_config_with_type(self):
+        rig = RelayRigMock()
+        rig.relays['r0'] = self.r0
+        rig.relays['r1'] = self.r1
+        config = {
+            'type':
+            'GenericRelayDevice',
+            'name':
+            '.',
+            'relays': [{
+                'name': 'r0',
+                'pos': 'MockBoard/0'
+            }, {
+                'name': 'r1',
+                'pos': 'MockBoard/1'
+            }]
+        }
+        relay_device = rig.create_relay_device(config)
+        self.assertIsInstance(relay_device, GenericRelayDevice)
+
+    def test_create_relay_device_raise_on_type_not_found(self):
+        rig = RelayRigMock()
+        rig.relays['r0'] = self.r0
+        rig.relays['r1'] = self.r1
+        config = {
+            'type':
+            'SomeInvalidType',
+            'name':
+            '.',
+            'relays': [{
+                'name': 'r0',
+                'pos': 'MockBoard/0'
+            }, {
+                'name': 'r1',
+                'pos': 'MockBoard/1'
+            }]
+        }
+        with self.assertRaises(errors.RelayConfigError):
+            rig.create_relay_device(config)
+
+
+class TestSynchronizeRelays(unittest.TestCase):
+    def test_synchronize_relays(self):
+        Relay.transition_wait_time = .1
+        with SynchronizeRelays():
+            self.assertEqual(Relay.transition_wait_time, 0)
+        self.assertEqual(Relay.transition_wait_time, .1)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/acts/tests/sample/RelayDeviceSampleTest.py b/acts/tests/sample/RelayDeviceSampleTest.py
new file mode 100644
index 0000000..040ef62
--- /dev/null
+++ b/acts/tests/sample/RelayDeviceSampleTest.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python
+#
+#   Copyright 2016 - The Android Open Source Project
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+from acts import base_test
+from acts import test_runner
+from acts.controllers.relay_lib.relay import SynchronizeRelays
+
+
+class RelayDeviceSampleTest(base_test.BaseTestClass):
+    """ Demonstrates example usage of a configurable access point."""
+
+    def setup_class(self):
+        # Take devices from relay_devices.
+        self.relay_device = self.relay_devices[0]
+
+        # You can use this workaround to get devices by name:
+
+        relay_rig = self.relay_devices[0].rig
+        self.other_relay_device = relay_rig.devices['UniqueDeviceName']
+        # Note: If the "devices" key from the config is missing
+        # a GenericRelayDevice that contains every switch in the config
+        # will be stored in relay_devices[0]. Its name will be
+        # "GenericRelayDevice".
+
+    def setup_test(self):
+        # setup() will set the relay device to the default state.
+        # Unless overridden, the default state is all switches set to off.
+        self.relay_device.setup()
+
+    def teardown_test(self):
+        # clean_up() will set the relay device back to a default state.
+        # Unless overridden, the default state is all switches set to off.
+        self.relay_device.clean_up()
+
+    # Typical use of a GenericRelayDevice looks like this:
+    def test_relay_device(self):
+
+        # This function call will sleep until .25 seconds are up.
+        # Blocking_nc_for will emulate a button press, which turns on the relay
+        # (or stays on if it already was on) for the given time, and then turns
+        # off.
+        self.relay_device.relays['BT_Power_Button'].set_nc_for(.25)
+
+        # do_something_after_turning_on_bt_power()
+
+        # Note that the relays are mechanical switches, and do take real time
+        # to go from one state to the next.
+
+        self.relay_device.relays['BT_Pair'].set_nc()
+
+        # do_something_while_holding_down_the_pair_button()
+
+        self.relay_device.relays['BT_Pair'].set_no()
+
+        # do_something_after_releasing_bt_pair()
+
+        # Note that although cleanup sets the relays to the 'NO' state after
+        # each test, they do not press things like the power button to turn
+        # off whatever hardware is attached. When using a GenericRelayDevice,
+        # you'll have to do this manually.
+        # Other RelayDevices may handle this for you in their clean_up() call.
+        self.relay_device.relays['BT_Power_Button'].set_nc_for(.25)
+
+    def test_toggling(self):
+        # This test just spams the toggle on each relay.
+        for _ in range(0, 2):
+            self.relay_device.relays['BT_Power_Button'].toggle()
+            self.relay_device.relays['BT_Pair'].toggle()
+            self.relay_device.relays['BT_Reset'].toggle()
+            self.relay_device.relays['BT_SomethingElse'].toggle()
+
+    def test_synchronize_relays(self):
+        """Toggles relays using SynchronizeRelays().
+
+        This makes each relay do it's action at the same time, without waiting
+        after each relay to swap. Instead, all relays swap at the same time, and
+        the wait is done after exiting the with statement.
+        """
+        for _ in range(0, 10):
+            with SynchronizeRelays():
+                self.relay_device.relays['BT_Power_Button'].toggle()
+                self.relay_device.relays['BT_Pair'].toggle()
+                self.relay_device.relays['BT_Reset'].toggle()
+                self.relay_device.relays['BT_SomethingElse'].toggle()
+
+        # For more fine control over the wait time of relays, you can set
+        # Relay.transition_wait_time. This is not recommended unless you are
+        # using solid state relays, or async calls.
+
+
+if __name__ == "__main__":
+    test_runner.main()