Docs: Add tutorial for writing custom controllers (#993)

* Docs: Add tutorial for writing custom controllers

* Docs: Add tutorial for writing custom controllers

* Add missing 'is_on' attribute to SmartLight docstring and remove redundant type information from attributes docstring

---------

Co-authored-by: Kolin Lu <kolinlu@google.com>
diff --git a/docs/index.rst b/docs/index.rst
index 093c955..808397b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -11,7 +11,7 @@
    :caption: Contents:
 
    mobly
-
+   tutorial_custom_controller
 
 Indices and tables
 ==================
diff --git a/docs/tutorial_custom_controller.md b/docs/tutorial_custom_controller.md
new file mode 100644
index 0000000..10a7255
--- /dev/null
+++ b/docs/tutorial_custom_controller.md
@@ -0,0 +1,157 @@
+# Custom Controller Tutorial
+
+Mobly enables users to control custom hardware devices (e.g., smart lights, switches) by creating custom controller modules. This tutorial explains how to implement a production-ready custom controller.
+
+## Controller Module Interface
+
+A Mobly controller module needs to implement specific top-level functions to manage the device lifecycle.
+
+**Required functions:**
+
+* **`create(configs)`**: Instantiates controller objects from the configuration.
+* **`destroy(objects)`**: Cleans up resources when the test ends.
+
+**Optional functions:**
+
+* **`get_info(objects)`**: Returns device information for the test report. If not implemented, no controller information will be included in the result.
+
+## Implementation Example
+
+The following example demonstrates a custom controller for a **Smart Light**, featuring type hinting, input validation, and fault-tolerant cleanup.
+
+### 1. Controller Module (`smart_light.py`)
+
+Save this code as `smart_light.py`.
+
+```python
+"""Mobly controller module for a Smart Light."""
+
+import logging
+from typing import Any, Dict, List
+
+# The key used in the config file to identify this controller.
+MOBLY_CONTROLLER_CONFIG_NAME = "SmartLight"
+
+class SmartLight:
+    """A class representing a smart light device.
+    
+    Attributes:
+        name: the name of the device.
+        ip: the IP address of the device.
+        is_on: True if the light is currently on, False otherwise.
+    """
+
+    def __init__(self, name: str, ip: str):
+        self.name = name
+        self.ip = ip
+        self.is_on = False
+        logging.info("Initialized SmartLight [%s] at %s", self.name, self.ip)
+
+    def power_on(self):
+        """Turns the light on."""
+        self.is_on = True
+        logging.info("SmartLight [%s] turned ON", self.name)
+
+    def power_off(self):
+        """Turns the light off."""
+        self.is_on = False
+        logging.info("SmartLight [%s] turned OFF", self.name)
+
+    def close(self):
+        """Simulates closing the connection."""
+        logging.info("SmartLight [%s] connection closed", self.name)
+
+
+def create(configs: List[Dict[str, Any]]) -> List[SmartLight]:
+    """Creates SmartLight instances from a list of configurations.
+
+    Args:
+        configs: A list of dicts, where each dict represents a configuration
+            for a SmartLight device.
+
+    Returns:
+        A list of SmartLight objects.
+
+    Raises:
+        ValueError: If a required configuration parameter is missing.
+    """
+    devices = []
+    for config in configs:
+        if "name" not in config or "ip" not in config:
+            raise ValueError(
+                f"Invalid config: {config}. 'name' and 'ip' are required."
+            )
+        
+        devices.append(SmartLight(
+            name=config["name"],
+            ip=config["ip"]
+        ))
+    return devices
+
+
+def destroy(objects: List[SmartLight]) -> None:
+    """Cleans up SmartLight instances.
+    
+    Args:
+        objects: A list of SmartLight objects to be destroyed.
+    """
+    for light in objects:
+        try:
+            if light.is_on:
+                light.power_off()
+            light.close()
+        except Exception:
+            # Catching broad exceptions ensures that a failure in one device
+            # does not prevent others from being cleaned up.
+            logging.exception("Failed to clean up SmartLight [%s]", light.name)
+
+
+def get_info(objects: List[SmartLight]) -> List[Dict[str, Any]]:
+    """Returns information for the test result.
+
+    Args:
+        objects: A list of SmartLight objects.
+
+    Returns:
+        A list of dicts containing device information.
+    """
+    return [{"name": light.name, "ip": light.ip} for light in objects]
+
+```
+
+### 2. Controller Module (`smart_light.py`)
+
+To use the custom controller, register it in your test script.
+
+```python
+from mobly import base_test
+from mobly import test_runner
+import smart_light
+
+class LightTest(base_test.BaseTestClass):
+    def setup_class(self):
+        # Register the custom controller
+        self.lights = self.register_controller(smart_light)
+
+    def test_turn_on(self):
+        light = self.lights[0]
+        light.power_on()
+        
+        # Verify the light is on
+        if not light.is_on:
+            raise signals.TestFailure(f"Light {light.name} should be on!")
+
+if __name__ == "__main__":
+    test_runner.main()
+```
+
+### 3. Configuration File (config.yaml)
+Define the device in your configuration file using the SmartLight key.
+```yaml
+TestBeds:
+  - Name: BedroomTestBed
+    Controllers:
+      SmartLight:
+        - name: "BedLight"
+          ip: "192.168.1.50"
+```