What Are You Building?
The Simpson's House project turns a model house into a real, working smart home.
You'll use a Raspberry Pi (a small Linux computer) to control physical hardware —
a light, a garage door servo, and a front door servo — all from a custom iPad app you can style and customise.
This is a real IoT (Internet of Things) system. The same architecture is used in commercial smart home
products like Google Nest and Philips Hue. By the end you'll understand exactly how those systems work under the hood.
System Architecture
Every command you tap on the iPad travels through four distinct layers:
📱
iPad App
Swift Playgrounds
You design this part
🖥️
Raspberry Pi
Mosquitto broker
+ Python listener
🔌
Hardware
LED, 2× servo motors
Hardware Components
Each Pi kit contains:
| Component | Quantity | Purpose | GPIO Pin (BCM) |
| Raspberry Pi 4 | 1 | Runs Linux, Python, MQTT broker | — |
| LED + 220Ω resistor | 1 | Living room light | GPIO 17 |
| SG90 Servo Motor | 1 | Garage door (0° closed, 90° open) | GPIO 27 |
| SG90 Servo Motor | 1 | Front door (0° closed, 90° open) | GPIO 23 |
Key Concept: MQTT Pub/Sub
MQTT is a lightweight messaging protocol designed for IoT devices. It uses a
publish/subscribe (pub/sub) pattern — the same idea as a YouTube channel:
- Publisher — the iPad publishes a message to a topic (e.g.
home/light) - Broker — Mosquitto (running on the Pi) receives and routes messages
- Subscriber — the Python script subscribes to topics and reacts when messages arrive
The three topics used in this project:
| MQTT Topic | Commands | Controls |
| home/light | ON / OFF | LED on GPIO 17 |
| home/garage | OPEN / CLOSE | Garage servo on GPIO 27 |
| home/door | ON / OFF | Front door servo on GPIO 23 |
Key Concept: Event-Driven Programming
The Python backend uses event-driven programming. Instead of constantly checking
"has a message arrived?", the program registers callback functions that the MQTT library
calls automatically when specific events happen.
# mqttlistener.py — callback registration
client = mqtt.Client()
# Register callbacks — these run automatically when events occur
client.on_connect = on_connect # ← called when broker accepts connection
client.on_disconnect = on_disconnect # ← called when connection drops
client.on_message = on_message # ← called EVERY time a message arrives
client.connect("localhost", 1883)
client.loop_forever() # blocks here, processing events indefinitely
Key Concept: PWM and Servo Control
A servo motor doesn't understand "open" or "closed" — it understands angles (0–180°).
We use PWM (Pulse Width Modulation) to communicate the angle. PWM rapidly switches a
GPIO pin on and off at 50 Hz; the ratio of on-time to off-time encodes the target angle.
# mqttlistener.py — servo angle control
def set_servo_angle(pwm, angle: int, label: str = "Servo") -> bool:
# Convert 0–180° angle into a 2–12% PWM duty cycle
# 0° → 2% duty (1ms pulse at 50Hz)
# 90° → 7% duty (1.5ms pulse)
# 180°→ 12% duty (2ms pulse)
duty = (angle / 180.0) * 10 + 2
pwm.ChangeDutyCycle(duty)
time.sleep(0.8) # wait for servo to physically move
pwm.ChangeDutyCycle(0) # stop pulses to prevent jitter
# Using it:
set_servo_angle(GARAGE_SERVO_PWM, 90, "Garage Door") # OPEN
set_servo_angle(GARAGE_SERVO_PWM, 0, "Garage Door") # CLOSE
Your Tasks
Work through these steps in order. Your Pi is already set up and running — your job is to customise the iPad app frontend.
The Swift App: How It's Structured
The iPad app has two types of files — files you customise
(marked with // CUSTOMIZE: comments) and files
you never edit (marked // ⚠️ DO NOT EDIT).
Understanding the property wrapper pattern first will make the customisation much easier.
@StateObject, @ObservedObject, and @Binding
The app's state — whether each device is on or off, whether the MQTT client is connected —
all lives in SimpsonsHouseMQTTClient, an ObservableObject class.
Three Swift property wrappers connect views to this shared state:
// ContentView.swift — the root view owns the MQTT client
struct ContentView: View {
@StateObject private var mqttClient = SimpsonsHouseMQTTClient()
// @StateObject creates and owns the object for the lifetime of this view
}
// DeviceControlsSection.swift — a child view that reads the same client
struct DeviceControlsSection: View {
@ObservedObject var mqttClient: SimpsonsHouseMQTTClient
// @ObservedObject watches an object created elsewhere (passed in)
}
// ConnectionStatusCard.swift — needs to toggle sheet booleans in the parent
struct ConnectionStatusCard: View {
@ObservedObject var mqttClient: SimpsonsHouseMQTTClient
@Binding var showingInfo: Bool // @Binding = two-way link to parent's @State
@Binding var showingLogs: Bool
}
@StateObject Creates and owns the object
Used once, at the top. ContentView creates the MQTT client and keeps it alive for the whole app.
ContentView.swift
@ObservedObject Watches an object passed in
Child views receive the client from ContentView. They watch it for changes but don't own it.
DeviceControlsSection, GarageDoorCard, etc.
@Binding Two-way link to a parent @State
The sheet booleans (showingInfo, showingLogs) live in ContentView; @Binding lets a child toggle them.
ConnectionStatusCard.swift
HeroSectionView.swift — Top banner: icon, title, subtitle, background
What you can change
- → SF Symbol name (
house.fill, bolt.house.fill, tv.fill, sun.max.fill…) - → Icon foreground colour
- → Wrap icon in a
ZStack with a Circle badge background - → App title and subtitle text strings
- → Solid colour, gradient, or
.ultraThinMaterial background with .clipShape(RoundedRectangle(...)) - → Border overlay using
.stroke()
Example customisations
// Swap the SF Symbol:
Image(systemName: "bolt.house.fill")
.font(.system(size: 56))
.foregroundStyle(.yellow)
// Add a coloured badge circle behind the icon:
ZStack {
Circle()
.fill(Color.yellow)
.frame(width: 90, height: 90)
Image(systemName: "house.fill")
.font(.system(size: 40))
.foregroundStyle(.white)
}
// Add a gradient card background:
.background(LinearGradient(
colors: [.yellow, .orange],
startPoint: .top, endPoint: .bottom
))
.clipShape(RoundedRectangle(cornerRadius: 20))
DeviceCard.swift — Reusable tile for Light and Front Door
What you can change
- → Card background that changes colour when
isOn is true - → Glowing drop shadow when device is active
- → Icon wrapped in a coloured circle
ZStack - → ON/OFF status as a styled
Capsule() pill badge - → Font weight, size, and colour of title / subtitle labels
Key insight: the isOn Bool and accentColor Color are passed as parameters — use them in your styling to make the card visually react to state.
Example customisations
// Active glow effect — the whole card changes when the device is ON:
.background(isOn ? accentColor.opacity(0.12) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: isOn ? accentColor.opacity(0.4) : .clear, radius: 8)
// Coloured badge pill for ON/OFF status:
Text(isOn ? "ON" : "OFF")
.font(.caption2).fontWeight(.bold)
.foregroundStyle(isOn ? accentColor : .secondary)
.padding(.horizontal, 8).padding(.vertical, 2)
.background(isOn ? accentColor.opacity(0.15) : Color(.systemGray6))
.clipShape(Capsule())
// Icon in a coloured circle that responds to state:
ZStack {
Circle()
.fill(isOn ? accentColor.opacity(0.2) : Color(.systemGray6))
.frame(width: 48, height: 48)
Image(systemName: icon)
.font(.title2)
.foregroundStyle(isOn ? accentColor : .secondary)
}
GarageDoorCard.swift — Full-width card with OPEN + CLOSE buttons
What you can change
- → OPEN button background (active state = orange)
- → CLOSE button background (active state = blue)
- → Card background tint when door is open
- → Border
.overlay() that appears in orange when open - → State label styled as a coloured badge
Capsule() - → Icon switches automatically:
garage ↔ garage.open
Example customisations
// Coloured OPEN/CLOSE buttons with active state highlight:
// CLOSE button — highlighted (blue) when door is closed
.foregroundStyle(!mqttClient.deviceStates.garageOpen ? .white : .blue)
.background(!mqttClient.deviceStates.garageOpen ? Color.blue : Color.blue.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
// OPEN button — highlighted (orange) when door is open
.foregroundStyle(mqttClient.deviceStates.garageOpen ? .white : .orange)
.background(mqttClient.deviceStates.garageOpen ? Color.orange : Color.orange.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
// Card background that tints orange when the door is open:
.background(mqttClient.deviceStates.garageOpen
? Color.orange.opacity(0.05)
: Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(mqttClient.deviceStates.garageOpen ? Color.orange : .clear, lineWidth: 2)
)
ConnectionStatusCard.swift — Connect / Disconnect panel with status dot
What you can change
- → Connect button — bold blue background, white text
- → Disconnect button — red background to signal danger
- → Status pill — green/red capsule background behind the dot
- → Pulsing
.animation(.easeInOut.repeatForever()) on the status dot - → Wrap the whole card in a
RoundedRectangle with shadow
Example customisations
// Styled Connect / Disconnect button:
.foregroundStyle(.white)
.background(mqttClient.isConnected ? Color.red : Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 16))
// Status pill with capsule background:
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(mqttClient.isConnected ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
.clipShape(Capsule())
// Pulsing dot animation when connected:
Circle()
.fill(mqttClient.isConnected ? Color.green : Color.red)
.frame(width: 10, height: 10)
.scaleEffect(mqttClient.isConnected ? 1.2 : 1.0)
.animation(.easeInOut(duration: 0.8).repeatForever(), value: mqttClient.isConnected)
ConnectionPromptView.swift — Offline placeholder shown when not connected
What you can change
- → Icon — try
wifi.exclamationmark, house.slash.fill, or antenna.radiowaves.left.and.right.slash - → Heading and description text — personalise to your project's theme
- → Card background with
.background(Color(.secondarySystemBackground)) and .clipShape(RoundedRectangle(cornerRadius: 20)) - → A fade-in
.transition(.opacity) or scale animation on .onAppear
ContentView.swift — Overall screen background colour
The ScrollView inside ContentView has one customisation point — its background.
Look for the comment // CUSTOMIZE: Change the background colour of the whole screen.
- →
.background(Color.yellow.opacity(0.05)) — subtle warm tint - →
.background(Color("MyBackgroundColor")) — named asset colour - →
.background(LinearGradient(colors: [.blue.opacity(0.1), .purple.opacity(0.05)], startPoint: .top, endPoint: .bottom))
# SSH from your Mac (Terminal) or from iPad using Secure ShellFish
ssh pi@10.20.12.XXX
# Password: tkcraspberry
# Useful commands once connected:
sudo systemctl status simpsons-house # is the controller running?
sudo journalctl -u simpsons-house -f # watch live logs
python3 gpio_test.py # test hardware before coding