The home automation market is designed to make you think this is hard. You need a hub. The hub needs a subscription. The sensors need the right protocol. The light bulbs need to be from the same manufacturer as the thermostat. The app needs a cloud account. The cloud account needs your location data. And after all that, you still can't control a dumb appliance — a window fan, a space heater, a sump pump — because those devices don't speak Zigbee, Z-Wave, Matter, or any protocol the $200 hub understands.
A $2 relay module and a Raspberry Pi can automate any switch in your house.
A $2 relay module and a Raspberry Pi can automate any switch in your house. Any switch. The relay is an electrically-operated switch: your Pi sends a 3.3V signal on a GPIO pin, the relay clicks, and whatever is wired through it turns on or off. The window fan that has a physical toggle switch? Wire it through a relay. The grow light in the basement? Relay. The space heater in the garage that you forget to turn off? Relay, plus a temperature sensor, plus three lines of Python.
The relay doesn't care about protocols, ecosystems, or cloud subscriptions. It cares about voltage. And once you understand how relays work, the entire smart-home industry starts looking like an expensive abstraction over a problem that was solved by electromechanical switches in the 1830s.
A relay module is a small PCB with one or more relay switches, a control circuit, and screw terminals for the load (the device you're switching). The Pi sends a logic-level signal (3.3V HIGH or LOW) to the module's input pin. The module's control circuit uses that signal to energize an electromagnet inside the relay, which physically moves a metal contact to close (or open) the power circuit.
A $2 relay module and a Raspberry Pi can automate any switch in your house. The relay is the bridge between your Pi's 3.3V logic and your home's 120/240V world. Everything else — schedules, sensors, dashboards — is just software on top of that bridge.
Most relay modules you'll encounter have three screw terminals per channel:
For most automation use cases, you wire through COM and NO. The default state is OFF. When your Pi energizes the relay, the device turns on. When the Pi de-energizes it (or loses power, or crashes), the device turns off. This is the safe default — if something goes wrong with the Pi, everything connected through relays simply turns off.
Relay modules switch high-voltage circuits. In North America, that's 120V AC. In Europe, 240V AC. Both are lethal. If you are wiring a relay to a mains-powered device, you must know what you are doing. If you don't, start with low-voltage DC devices (12V LED strips, 5V USB fans) and work up only after you're comfortable with the concepts. Never work on mains wiring with the circuit energized. Never leave exposed mains connections. Use proper enclosures, crimped connectors, and strain relief. This is not software — mistakes here cause fires and electrocution.
For learning and prototyping, stay in low-voltage territory. A 5V USB-powered fan, a 12V LED strip with a barrel-jack power supply, or even just the relay module clicking on and off with nothing connected to the load terminals — all of these teach the same GPIO-to-relay pattern without any mains voltage risk.
The gpiozero library treats a relay the same as an LED — it's an output device that you turn on, turn off, or toggle. The relay module's input pin connects to a GPIO pin on the Pi, and gpiozero handles the rest.
#!/usr/bin/env python3
"""relay_control.py — basic relay control with gpiozero."""
from gpiozero import OutputDevice
from time import sleep
# Relay connected to GPIO pin 17
# active_high=False for modules that trigger on LOW signal
# (most cheap relay modules are active-low)
relay = OutputDevice(17, active_high=False, initial_value=False)
print("Relay OFF (initial state)")
sleep(2)
relay.on()
print("Relay ON — device is powered")
sleep(5)
relay.off()
print("Relay OFF — device is unpowered")
Most inexpensive relay modules from Amazon and AliExpress are active-low: they energize the relay when the input pin is pulled LOW (0V), not HIGH (3.3V). This is counterintuitive. Set active_high=False in gpiozero so that relay.on() actually turns the relay on, regardless of the module's logic level. Check your module's datasheet or test it: if relay.on() turns the device off, flip the active_high parameter.
For a multi-relay setup — say, controlling a fan, a light, and a pump independently — create one OutputDevice per channel:
from gpiozero import OutputDevice
fan_relay = OutputDevice(17, active_high=False, initial_value=False)
light_relay = OutputDevice(27, active_high=False, initial_value=False)
pump_relay = OutputDevice(22, active_high=False, initial_value=False)
# Control independently
fan_relay.on() # fan starts
light_relay.on() # light turns on
pump_relay.off() # pump stays off
A relay is an OutputDevice in gpiozero. relay.on() energizes it, relay.off() de-energizes it. Most cheap relay modules are active-low — set active_high=False or your logic will be inverted.
The simplest useful automation is a light that turns on at sunset and off at sunrise. No sensors. No cloud. Just the Pi's clock and a calculation.
The astral library computes sunrise and sunset times for any location on Earth. Combined with a relay, this is a complete outdoor lighting controller:
#!/usr/bin/env python3
"""sunset_light.py — turns a relay on at sunset, off at sunrise."""
from gpiozero import OutputDevice
from astral import LocationInfo
from astral.sun import sun
from datetime import datetime, timezone
import time
# ── Configuration ───────────────────────────────────────────────────
LATITUDE = 30.0444 # Cairo, Egypt (replace with your location)
LONGITUDE = 31.2357
TIMEZONE = "Africa/Cairo"
RELAY_PIN = 17
# ── Setup ───────────────────────────────────────────────────────────
relay = OutputDevice(RELAY_PIN, active_high=False, initial_value=False)
location = LocationInfo("Home", "Region", TIMEZONE, LATITUDE, LONGITUDE)
def is_after_sunset():
"""Return True if the current time is between sunset and sunrise."""
now = datetime.now(timezone.utc)
s = sun(location.observer, date=now, tzinfo=timezone.utc)
sunrise = s["sunrise"]
sunset = s["sunset"]
# Between sunset and midnight, or between midnight and sunrise
if sunset < sunrise:
# Normal case: sunset is today, sunrise is tomorrow
return now >= sunset or now < sunrise
else:
# Edge case near polar regions
return sunset <= now < sunrise
def main():
print(f"Sunset light controller started")
print(f"Location: {LATITUDE}, {LONGITUDE}")
while True:
if is_after_sunset():
if not relay.is_active:
relay.on()
print(f"[{datetime.now().isoformat()}] Sunset — light ON")
else:
if relay.is_active:
relay.off()
print(f"[{datetime.now().isoformat()}] Sunrise — light OFF")
# Check every 60 seconds — sunrise/sunset don't need millisecond precision
time.sleep(60)
if __name__ == "__main__":
main()
pip install astral gpiozero
Deploy this as a systemd service (Chapter 15 covered the pattern), and you have an outdoor light that follows the sun for the cost of a Pi, a relay module, and twenty minutes of setup. No app. No subscription. No cloud account. The Pi calculates sunrise and sunset locally, and the relay clicks.
A Pi, a relay, and the astral library give you a sunset-triggered light controller with no cloud dependency, no app, and no subscription — for under $40 in hardware.
Scheduled automation handles the predictable. Sensor-triggered automation handles the reactive — a fan that starts when the temperature exceeds a threshold, a pump that activates when soil moisture drops too low, a heater that cycles based on ambient temperature.
Here is a thermostat pattern: a temperature sensor (or the Pi's own CPU temperature as a stand-in) triggers a relay when a threshold is crossed:
#!/usr/bin/env python3
"""thermostat.py — relay-based temperature control."""
from gpiozero import OutputDevice, CPUTemperature
from time import sleep
# ── Configuration ───────────────────────────────────────────────────
FAN_RELAY_PIN = 17
TEMP_ON_THRESHOLD = 55.0 # Celsius — turn fan ON above this
TEMP_OFF_THRESHOLD = 45.0 # Celsius — turn fan OFF below this
CHECK_INTERVAL = 10 # seconds between checks
# ── Setup ───────────────────────────────────────────────────────────
fan = OutputDevice(FAN_RELAY_PIN, active_high=False, initial_value=False)
cpu = CPUTemperature()
def main():
print(f"Thermostat started: ON above {TEMP_ON_THRESHOLD}°C, "
f"OFF below {TEMP_OFF_THRESHOLD}°C")
while True:
temp = cpu.temperature
if temp >= TEMP_ON_THRESHOLD and not fan.is_active:
fan.on()
print(f"[{temp:.1f}°C] Fan ON — above threshold")
elif temp <= TEMP_OFF_THRESHOLD and fan.is_active:
fan.off()
print(f"[{temp:.1f}°C] Fan OFF — below threshold")
else:
state = "ON" if fan.is_active else "OFF"
print(f"[{temp:.1f}°C] Fan {state} — no change")
sleep(CHECK_INTERVAL)
if __name__ == "__main__":
main()
Notice the two thresholds — TEMP_ON_THRESHOLD and TEMP_OFF_THRESHOLD — with a 10-degree gap. This is hysteresis, and it's essential. Without it, a single threshold at 50 degrees causes the relay to chatter: the temperature crosses 50, the fan turns on, the temperature drops to 49.8, the fan turns off, the temperature rises to 50.1, the fan turns on again. Rapid cycling damages relays, wastes energy, and creates an annoying clicking sound. The hysteresis gap ensures the fan stays on until the temperature drops meaningfully, and stays off until the temperature rises meaningfully.
Chapter 16 covered MQTT publish/subscribe. Here is where it pays off: multiple rooms, each with a Pi, a relay, and a sensor, all coordinated through a central broker.
#!/usr/bin/env python3
"""mqtt_relay.py — MQTT-controlled relay with status reporting."""
import json
import paho.mqtt.client as mqtt
from gpiozero import OutputDevice
BROKER_HOST = "pi-broker.local"
DEVICE_ID = "garage-01"
RELAY_PIN = 17
relay = OutputDevice(RELAY_PIN, active_high=False, initial_value=False)
def on_connect(client, userdata, flags, reason_code, properties):
if reason_code == 0:
# Subscribe to commands for this specific device
client.subscribe(f"home/{DEVICE_ID}/command", qos=1)
# Announce online status
client.publish(f"home/{DEVICE_ID}/status", "online", qos=1, retain=True)
print(f"Connected — listening for commands on home/{DEVICE_ID}/command")
def on_message(client, userdata, msg):
try:
data = json.loads(msg.payload.decode())
except json.JSONDecodeError:
return
action = data.get("action", "")
if action == "on":
relay.on()
elif action == "off":
relay.off()
elif action == "toggle":
relay.toggle()
else:
print(f"Unknown action: {action}")
return
# Report current state back
state = "on" if relay.is_active else "off"
client.publish(
f"home/{DEVICE_ID}/relay_state",
json.dumps({"state": state, "device": DEVICE_ID}),
qos=1,
retain=True,
)
print(f"Relay {state} (action: {action})")
def main():
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"relay-{DEVICE_ID}",
)
client.on_connect = on_connect
client.on_message = on_message
client.will_set(f"home/{DEVICE_ID}/status", "offline", qos=1, retain=True)
client.connect(BROKER_HOST, 1883, keepalive=60)
client.loop_forever()
if __name__ == "__main__":
main()
Now any device on the network can control the garage relay by publishing to home/garage-01/command:
# Turn on the garage heater from any machine on the network
mosquitto_pub -h pi-broker.local \
-t "home/garage-01/command" \
-m '{"action": "on"}'
The relay Pi reports its state back to home/garage-01/relay_state, so a dashboard subscriber always knows the current state of every relay in the house. Add a second relay Pi in the bedroom, subscribe to home/+/relay_state, and you have a centralized view of every automated device — without any of them knowing the others exist.
MQTT turns isolated relay controllers into a coordinated multi-room automation system. Each Pi controls its local hardware and reports state to the broker. A central dashboard subscribes to all state topics and displays the whole house at once.
There's a point where custom Python scripts stop being the right tool for home automation. That point is roughly when you have more than five or six automated devices and you want a mobile-friendly dashboard, conditional logic that non-programmers can modify, or integration with commercial devices (Zigbee bulbs, Z-Wave locks, WiFi plugs).
Home Assistant is the answer for most people who hit this wall. It's a Python-based platform that runs on a Pi, integrates with over 2,000 device types, provides a polished web dashboard, and has a mobile app for remote control. If your automation needs grow beyond "turn relay on at sunset," Home Assistant is where you graduate to.
# Install Home Assistant in a Docker container on your Pi
docker run -d \
--name homeassistant \
--privileged \
--restart=unless-stopped \
-v /home/hesham/homeassistant:/config \
-v /run/dbus:/run/dbus:ro \
--network=host \
ghcr.io/home-assistant/home-assistant:stable
Node-RED is the alternative for engineers who prefer visual programming. It runs on a Pi, provides a browser-based flow editor, and has nodes for GPIO, MQTT, HTTP, and hundreds of third-party services. If you think in flowcharts rather than scripts, Node-RED is the more natural tool.
# Install Node-RED on your Pi
bash <(curl -sL https://raw.githubusercontent.com/node-red/linux-installers/master/deb/update-nodejs-and-nodered)
# Enable and start
sudo systemctl enable nodered.service
sudo systemctl start nodered.service
Everything you've built in this chapter transfers. Home Assistant can call Python scripts, subscribe to MQTT topics, and control GPIO pins. Node-RED can import your MQTT topic hierarchy and relay-control logic. The sensor networks and relay patterns you built from scratch become the foundation for a more polished system — not throwaway code.
The decision framework is simple. If you have fewer than five automated devices and you're comfortable writing Python, custom scripts give you total control with zero overhead. If you have more than five devices, want a dashboard accessible from your phone, or need to integrate with commercial smart-home products, install Home Assistant on the same Pi and let it manage the complexity.
Home Assistant is where you graduate when custom scripts stop scaling. But the MQTT topics, relay patterns, and sensor networks you built from scratch become the foundation — not throwaway code.
Connect a relay module to GPIO 17 (signal), 5V (power), and GND. Run the basic relay_control.py script. Listen for the click. If you hear it, the relay is switching. Start with no load connected — just the click confirms the circuit works.
Pick a low-voltage device — a USB fan, a 12V LED strip — and wire it through the relay's NO and COM terminals. Deploy sunset_light.py (or modify the schedule for your use case) as a systemd service. Confirm the device turns on and off at the expected times.
Deploy thermostat.py with the CPU temperature sensor as a stand-in. Set the ON threshold 10 degrees above idle and the OFF threshold 5 degrees above idle. Run a CPU stress test (stress --cpu 4) and watch the relay respond. Verify it doesn't chatter.
Deploy mqtt_relay.py on a Pi with a relay. From another machine, publish a command to home/{device}/command and verify the relay toggles. Confirm the state is reported back to home/{device}/relay_state.
If you have more than five devices to automate, install Home Assistant in Docker on your Pi. Configure it to subscribe to your existing MQTT topics. See your relay states appear in the HA dashboard without changing any code on the relay Pis.