Examples · Internet of Things
A greenhouse that waters itself
Soil moisture for four zones and a small weather forecast go in; the model decides through function calling which valves to open and for how long. Your code holds the guardrails, refuses every unsafe call, and keeps the log you can trust.
Intermediate · 40 min
The idea
The school garden in Legnano has four zones with a drip line and a valve each: tomatoes and lettuce in the greenhouse, herbs and strawberries outside. Every morning at 06:00 someone has to decide which valves to open, and for how long.
A model can make that decision from the soil readings and the forecast. With function calling it does not write text for a person: it asks your program to open_valve(zone, minutes) or to skip(zone, reason). The model only asks. Your code decides whether to do it, with three rules a model must never break:
- at most 15 minutes per valve;
- no watering outside if more than 5 mm of rain is forecast in the next 24 hours;
- never two valves at once: the pump cannot feed two drip lines.
The rules are in the system message too. Here you will see how often that is enough.
What you need
A key and the openai library, as in Your first API call in Python. If function calling is new to you, read Let the model call your functions first.
The data
The soil sensors, as moisture.json. Moisture and target are in percent:
{
"time": "2026-10-09 06:00",
"zones": {
"tomatoes": {"where": "greenhouse", "moisture": 14, "target": 35},
"lettuce": {"where": "greenhouse", "moisture": 38, "target": 40},
"herbs": {"where": "outside", "moisture": 19, "target": 30},
"strawberries": {"where": "outside", "moisture": 24, "target": 35}
}
}
The forecast, as forecast.json. Look at it closely: there is little rain today, and more tonight.
{
"place": "Legnano",
"periods": [
{"from": "2026-10-09 06:00", "to": "2026-10-09 12:00", "rain_mm": 0.0, "temp_max": 16},
{"from": "2026-10-09 12:00", "to": "2026-10-09 18:00", "rain_mm": 0.5, "temp_max": 19},
{"from": "2026-10-09 18:00", "to": "2026-10-10 00:00", "rain_mm": 3.0, "temp_max": 15},
{"from": "2026-10-10 00:00", "to": "2026-10-10 06:00", "rain_mm": 3.0, "temp_max": 12}
]
}
The program
Save it as water.py, next to the two files. The valves are simulated: the program keeps a clock and prints what it would do.
import json
import os
import sys
from datetime import datetime, timedelta
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
MODEL = sys.argv[1] if len(sys.argv) > 1 else "qwen3.8-27b"
MAX_MINUTES = 15 # per valve, per morning
RAIN_LIMIT = 5.0 # mm in the next 24 hours: above this, no watering outside
with open("moisture.json") as f:
soil = json.load(f)
with open("forecast.json") as f:
forecast = json.load(f)
zones = soil["zones"]
rain = sum(period["rain_mm"] for period in forecast["periods"]) # the code adds up the rain, not the model
tools = [
{"type": "function", "function": {
"name": "open_valve",
"description": "Open the valve of a zone now, for some minutes. Only one valve can be open at a time.",
"parameters": {
"type": "object",
"properties": {
"zone": {"type": "string", "enum": list(zones)},
"minutes": {"type": "integer"},
},
"required": ["zone", "minutes"],
},
}},
{"type": "function", "function": {
"name": "skip",
"description": "Do not water a zone this morning.",
"parameters": {
"type": "object",
"properties": {
"zone": {"type": "string", "enum": list(zones)},
"reason": {"type": "string"},
},
"required": ["zone", "reason"],
},
}},
]
clock = datetime.fromisoformat(soil["time"])
open_zone, closes_at = None, None
done = {} # zone -> what happened to it
def refuse(name, args):
"""The guardrails: the reason to refuse a call, or None when it is safe."""
zone = args.get("zone")
if zone not in zones:
return f"there is no zone {zone!r}"
if zone in done:
return f"{zone} was already handled this morning"
if name == "skip":
return None
minutes = args.get("minutes")
if not isinstance(minutes, int) or not 1 <= minutes <= MAX_MINUTES:
return f"minutes must be a whole number from 1 to {MAX_MINUTES}"
if zones[zone]["where"] == "outside" and rain > RAIN_LIMIT:
return f"{rain} mm of rain are forecast in the next 24 hours: no watering outside"
if open_zone:
return f"the valve of {open_zone} is open until {closes_at:%H:%M}: one valve at a time"
return None
messages = [
{"role": "system", "content": "You control the watering of the school garden in Legnano. For every zone, "
"call open_valve or skip, once. Water a zone when its moisture is below its "
"target: one minute of watering raises the moisture by about 1 point. "
f"Rules: at most {MAX_MINUTES} minutes per valve; only one valve open at a "
f"time; no watering outside if more than {RAIN_LIMIT} mm of rain are forecast "
"in the next 24 hours. When you are done, write one short line per zone."},
{"role": "user", "content": f"Soil now:\n{json.dumps(soil)}\n\nForecast:\n{json.dumps(forecast)}"},
]
for turn in range(8):
answer = client.chat.completions.create(model=MODEL, messages=messages, tools=tools, temperature=0)
message = answer.choices[0].message
if not message.tool_calls:
print("The model:", (message.content or "").strip())
break
messages.append(message.model_dump(exclude_none=True))
for call in message.tool_calls:
args = json.loads(call.function.arguments)
reason = refuse(call.function.name, args)
if reason:
result = f"REFUSED: {reason}"
elif call.function.name == "open_valve":
open_zone, closes_at = args["zone"], clock + timedelta(minutes=args["minutes"])
result = f"valve of {open_zone} open from {clock:%H:%M} to {closes_at:%H:%M}"
done[args["zone"]] = result
else:
result = f"{args['zone']} skipped"
done[args["zone"]] = result
print(f"{clock:%H:%M} {call.function.name}({json.dumps(args)})\n -> {result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
if open_zone: # the valve runs, closes, and only then the model may open the next one
clock, open_zone = closes_at, None
print(f"\nRain forecast in the next 24 hours: {rain} mm")
for zone, z in zones.items():
print(f" {zone:13} {z['where']:10} moisture {z['moisture']:2} / {z['target']} "
f"{done.get(zone, 'NOT HANDLED')}")
Run it with python3 water.py, or give another model: python3 water.py gemma-4-26b. A morning takes two to four calls. In our runs it took from 3 seconds (Gemma) to over 30 (the Qwens, on a busy afternoon).
All the calls in one answer of the model happen at the same moment, the moment the answer arrives. Between two answers, the open valve runs and closes. So a model that asks for two valves in one answer is asking for two valves at once.
What you should see
06:00 open_valve({"zone": "tomatoes", "minutes": 15})
-> valve of tomatoes open from 06:00 to 06:15
06:00 open_valve({"zone": "lettuce", "minutes": 2})
-> REFUSED: the valve of tomatoes is open until 06:15: one valve at a time
06:00 skip({"zone": "herbs", "reason": "Rain forecast 6.5 mm in next 24h exceeds 5.0 mm threshold; no outdoor watering allowed."})
-> herbs skipped
06:00 skip({"zone": "strawberries", "reason": "Rain forecast 6.5 mm in next 24h exceeds 5.0 mm threshold; no outdoor watering allowed."})
-> strawberries skipped
06:15 open_valve({"zone": "lettuce", "minutes": 2})
-> valve of lettuce open from 06:15 to 06:17
The model: - **Tomatoes (greenhouse):** Watered 15 min (max allowed). Moisture 14 → ~29; still below target 35, will need more later.
- **Lettuce (greenhouse):** Watered 2 min. Moisture 38 → ~40, at target.
- **Herbs (outside):** Skipped — 6.5 mm rain forecast in next 24 h exceeds the 5.0 mm threshold.
- **Strawberries (outside):** Skipped — 6.5 mm rain forecast in next 24 h exceeds the 5.0 mm threshold.
Rain forecast in the next 24 hours: 6.5 mm
tomatoes greenhouse moisture 14 / 35 valve of tomatoes open from 06:00 to 06:15
lettuce greenhouse moisture 38 / 40 valve of lettuce open from 06:15 to 06:17
herbs outside moisture 19 / 30 herbs skipped
strawberries outside moisture 24 / 35 strawberries skipped
Qwen3.8 knew the rule about one valve: it is in the system message and in the description of open_valve. It still asked for the lettuce while the tomatoes were watering. The code refused, the model read the refusal, and it asked again at 06:15. The plan was good; only the timing was unsafe, and the code caught it.
What the guardrails caught
We ran the program 16 times with four models. In 14 runs the code had to refuse at least one call:
| Model | Runs | One valve at a time | Rain | Max 15 minutes |
|---|---|---|---|---|
qwen3.8-27b |
5 | refused in 3 runs | never broken | never broken |
qwen3.6-35b |
4 | refused in 4 runs | never broken | never broken |
gemma-4-26b |
4 | never broken | refused in 4 runs: herbs, 11 minutes | never broken |
ministral-3-14b |
3 | see below | refused in 3 runs: herbs, 11 minutes | refused in 2 runs: tomatoes, 21 minutes |
- Gemma opened one valve per answer, as asked. But in every run it wanted 11 minutes for the herbs, which are dry, although 6.5 mm of rain is coming. Only after the refusal did it skip them.
- Ministral asked for the tomatoes and the herbs in the same answer, in every run: the rain rule refused the herbs before the one-valve rule could. Twice it asked for 21 minutes of tomatoes, the full gap from 14 to 35, past the limit it had been given.
- The Qwens never broke the rain rule or the limit. They broke the one-valve rule, which is the hardest to keep in mind: it is about time, not about numbers. Qwen3.8 kept all three rules in two runs of five: one perfect morning, and one where it skipped the lettuce (see below).
The zone check and the “already handled” check never fired in our runs. They cost two lines each, and they make a wrong call harmless.
The model's summary is not the log
Two decisions were wrong, and no guardrail could catch them, because they were not unsafe:
- Ministral skipped the lettuce in all three runs, with reasons like “Moisture already above target (38/40).” The lettuce is at 38, the target is 40.
- Qwen3.8, in one run of five, skipped the lettuce: “Only one valve can be open at a time; tomatoes has a much larger deficit (21 pts vs 2 pts).” It read the rule as if it meant “one valve per morning”.
And after its refusals, Ministral never called skip for the herbs. Its summary says “herbs: Skipped (rain forecast > 5.0 mm)”; the table printed by your code says NOT HANDLED. The table comes from what your code did, and it is the one to trust. Print the real moisture next to every decision, as here, and a person sees “38 / 40 lettuce skipped” at a glance.
Good to know
- Keep the rules in the prompt, and in the code. In the prompt they make good plans more likely; in the code they make bad calls impossible. In our runs the prompt alone kept the rules in 2 mornings out of 16.
- A refusal is information. Send it back as the tool result, with the reason. Gemma and the Qwens acted on it every time: they skipped the herbs, or asked for the lettuce again once the tomatoes were done. Ministral asked for the herbs a second time in two runs, and was refused again.
- Let the code do the sums. The rule depends on 0.0 + 0.5 + 3.0 + 3.0 mm. The program adds them up; it does not ask the model whether it rains enough.
- Real valves need a timer of their own. If your program crashes while a valve is open, the water keeps running. A safe controller closes every valve after a maximum time, whatever the software says.
- No
max_tokenshere.qwen3.8-27breasons silently before every answer, and that reasoning counts inmax_tokens: a small limit can cut an answer off before its tool calls.
Exercise
- Change the night rain to
1.0. Now the total is 4.5 mm: which models water the herbs and the strawberries, and for how long? - Remove “at most 15 minutes per valve” from the system message and run it three times with
qwen3.8-27b. Does the code have to step in now? - Write a check that is not a guardrail: print a warning when a zone below its target is skipped, and it is not outside on a rainy day. Which model's runs does it flag?
- Add a fifth zone,
seedlings, in the greenhouse, at 30 with a target of 32. You do not need to change the tools: why?
