Course ES
← back to chapter

Home automation

Command a simulated five-room house in plain language: "turn off the lights in

Showcase — Home automation

Command a simulated five-room house in plain language: "turn off the lights in every unoccupied room", "cool anything warmer than 24 down to 21". Conditional commands force the read-then-act pattern — the model must call get_state, reason over what came back, and only then issue the set_light and set_temperature calls that qualify. The reply appends a change list computed from the state diff in Python — not the model's own account of what it did — plus the final house state, so what you see is what actually happened.

Run

bash bootstrap-secrets.sh              # reads ../../../../.env, writes secrets/
docker compose up --build              # default: PROVIDER=openai

Open http://localhost:3000.

To run against Gemini instead:

PROVIDER=gemini docker compose up --build

What's where

  • backend/ai_openai.py — the house state, three tools (one read, two writes), a per-request dispatch closure, and the bounded loop
  • backend/ai_gemini.py — same house, Gemini spelling
  • backend/main.py — identical FastAPI loader; reads PROVIDER and dispatches
  • frontend/app/page.tsx — textarea + result
  • docker-compose.yml — two services, secrets mounted from ./secrets/

Every request gets a fresh copy of the house (copy.deepcopy), so runs are repeatable and two users can't trample each other's lights. The "Changes applied" section is a Python diff of before-vs-after state — models sometimes misreport their own side effects, so the report never relies on the model's memory of what it called.

Stop

docker compose down

Run locally

Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on localhost:3000.

Download home-automation.zip

unzip home-automation.zip
cd home-automation
bash bootstrap-secrets.sh   # one-time: pulls API keys into ./secrets
docker compose up --build   # default provider: openai
# or:  PROVIDER=gemini docker compose up --build

Type some input, pick a provider, and run the same code shown in Source against the live API. Sign-in required.


  

The same modules the Run button hits. The whole project (frontend, Dockerfile, compose) is in the ZIP under README.

backend/ai_openai.py

"""Week 6 - Showcase 2 (OpenAI): a simulated smart home, read-then-act.

Conditional commands force real chaining: "turn off lights in rooms
warmer than 22" can't be done blind. The model reads state, decides which
rooms qualify, then acts — and the tool results after each write confirm
what actually changed. Each request gets a fresh copy of the house, so
runs are repeatable.
"""
import copy
import json

from openai import OpenAI

_client = OpenAI()

_MODEL = "gpt-5.4-nano"

# The house resets per request — a session-scoped world, not shared state.
_INITIAL = {
    "living room": {"light": "on", "temperature_c": 23.5, "occupied": True},
    "kitchen": {"light": "on", "temperature_c": 21.0, "occupied": False},
    "bedroom": {"light": "off", "temperature_c": 24.0, "occupied": False},
    "office": {"light": "on", "temperature_c": 26.0, "occupied": True},
    "bathroom": {"light": "on", "temperature_c": 22.0, "occupied": False},
}

_TOOLS = [
    {
        "type": "function",
        "name": "get_state",
        "description": "Current state of every room: light, temperature_c, occupied.",
        "parameters": {"type": "object", "properties": {}},
    },
    {
        "type": "function",
        "name": "set_light",
        "description": "Switch one room's light on or off.",
        "parameters": {
            "type": "object",
            "properties": {
                "room": {"type": "string",
                         "enum": ["living room", "kitchen", "bedroom", "office", "bathroom"]},
                "state": {"type": "string", "enum": ["on", "off"]},
            },
            "required": ["room", "state"],
        },
    },
    {
        "type": "function",
        "name": "set_temperature",
        "description": "Set one room's thermostat target in Celsius (10-30).",
        "parameters": {
            "type": "object",
            "properties": {
                "room": {"type": "string",
                         "enum": ["living room", "kitchen", "bedroom", "office", "bathroom"]},
                "celsius": {"type": "number"},
            },
            "required": ["room", "celsius"],
        },
    },
]


def _make_dispatch(house: dict) -> dict:
    def get_state() -> dict:
        return house

    def set_light(room: str, state: str) -> dict:
        house[room]["light"] = state
        return {"room": room, "light": state}

    def set_temperature(room: str, celsius: float) -> dict:
        if not 10 <= celsius <= 30:
            return {"error": "celsius must be between 10 and 30"}
        house[room]["temperature_c"] = celsius
        return {"room": room, "temperature_c": celsius}

    return {"get_state": get_state, "set_light": set_light,
            "set_temperature": set_temperature}


def run(command: str) -> str:
    house = copy.deepcopy(_INITIAL)
    dispatch = _make_dispatch(house)

    input_list = [{"role": "user", "content": command.strip()}]
    response = None
    for _ in range(8):  # bounded — never ship an open loop
        response = _client.responses.create(
            model=_MODEL,
            instructions="You control a smart home. For conditional commands, "
                         "read the state first, then act only on rooms that "
                         "match. Your final summary must list every set_light "
                         "and set_temperature call you made in this conversation, "
                         "with the room and value — repeat them from the tool "
                         "results, never from memory.",
            input=input_list,
            tools=_TOOLS,
        )
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            break
        input_list += response.output
        for call in calls:
            args = json.loads(call.arguments)
            fn = dispatch.get(call.name)
            result = fn(**args) if fn else {"error": f"unknown tool {call.name}"}
            input_list.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result),
            })

    # Report side effects from the state diff, not from the model's memory —
    # the model narrates, but the source of truth is computed.
    changes = []
    for room, before in _INITIAL.items():
        for key, old in before.items():
            new = house[room][key]
            if new != old:
                changes.append(f"- {room}: {key} {old} -> {new}")
    changed = "\n".join(changes) if changes else "(none)"

    summary = response.output_text if response else ""
    return (f"{summary}\n\nChanges applied (computed from state):\n{changed}"
            f"\n\nFinal state:\n{json.dumps(house, indent=2)}")

backend/ai_gemini.py

"""Week 6 - Showcase 2 (Gemini): a simulated smart home, read-then-act."""
import copy
import json
import os

from google import genai
from google.genai import types

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

_MODEL = "gemini-3.1-flash-lite"

_INITIAL = {
    "living room": {"light": "on", "temperature_c": 23.5, "occupied": True},
    "kitchen": {"light": "on", "temperature_c": 21.0, "occupied": False},
    "bedroom": {"light": "off", "temperature_c": 24.0, "occupied": False},
    "office": {"light": "on", "temperature_c": 26.0, "occupied": True},
    "bathroom": {"light": "on", "temperature_c": 22.0, "occupied": False},
}

_ROOMS = ["living room", "kitchen", "bedroom", "office", "bathroom"]

_CONFIG = types.GenerateContentConfig(
    system_instruction="You control a smart home. For conditional commands, "
                       "read the state first, then act only on rooms that "
                       "match. Your final summary must list every set_light "
                       "and set_temperature call you made in this conversation, "
                       "with the room and value — repeat them from the tool "
                       "results, never from memory.",
    tools=[types.Tool(function_declarations=[
        types.FunctionDeclaration(
            name="get_state",
            description="Current state of every room: light, temperature_c, occupied.",
            parameters=types.Schema(type=types.Type.OBJECT, properties={}),
        ),
        types.FunctionDeclaration(
            name="set_light",
            description="Switch one room's light on or off.",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={
                    "room": types.Schema(type=types.Type.STRING, enum=_ROOMS),
                    "state": types.Schema(type=types.Type.STRING, enum=["on", "off"]),
                },
                required=["room", "state"],
            ),
        ),
        types.FunctionDeclaration(
            name="set_temperature",
            description="Set one room's thermostat target in Celsius (10-30).",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={
                    "room": types.Schema(type=types.Type.STRING, enum=_ROOMS),
                    "celsius": types.Schema(type=types.Type.NUMBER),
                },
                required=["room", "celsius"],
            ),
        ),
    ])],
)


def _make_dispatch(house: dict) -> dict:
    def get_state() -> dict:
        return house

    def set_light(room: str, state: str) -> dict:
        house[room]["light"] = state
        return {"room": room, "light": state}

    def set_temperature(room: str, celsius: float) -> dict:
        if not 10 <= celsius <= 30:
            return {"error": "celsius must be between 10 and 30"}
        house[room]["temperature_c"] = celsius
        return {"room": room, "temperature_c": celsius}

    return {"get_state": get_state, "set_light": set_light,
            "set_temperature": set_temperature}


def run(command: str) -> str:
    house = copy.deepcopy(_INITIAL)
    dispatch = _make_dispatch(house)

    contents = [types.Content(role="user", parts=[types.Part(text=command.strip())])]
    response = None
    for _ in range(8):  # bounded — never ship an open loop
        response = _client.models.generate_content(
            model=_MODEL, contents=contents, config=_CONFIG,
        )
        if not response.function_calls:
            break
        contents.append(response.candidates[0].content)
        parts = []
        for fc in response.function_calls:
            fn = dispatch.get(fc.name)
            result = fn(**fc.args) if fn else {"error": f"unknown tool {fc.name}"}
            parts.append(types.Part.from_function_response(name=fc.name, response=result))
        contents.append(types.Content(role="user", parts=parts))

    # Report side effects from the state diff, not from the model's memory —
    # the model narrates, but the source of truth is computed.
    changes = []
    for room, before in _INITIAL.items():
        for key, old in before.items():
            new = house[room][key]
            if new != old:
                changes.append(f"- {room}: {key} {old} -> {new}")
    changed = "\n".join(changes) if changes else "(none)"

    summary = (response.text or "") if response else ""
    return (f"{summary}\n\nChanges applied (computed from state):\n{changed}"
            f"\n\nFinal state:\n{json.dumps(house, indent=2)}")

Project files

  • .gitignore
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json