Merge "Integrated relay controllers into ACTS" am: cca6e58896 am: 788f56d290 am: f8206d3f40
am: d5c68b8176

Change-Id: Ida56d20bad51daad47a1912b5f3dcd122c52551c
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg
index ed6c102..cad4c39 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
 commit_message_check = ./tools/commit_message_check.py
 
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()