Small Models, Real Switches: A Local AI Home Assistant on a Raspberry Pi 4
A 29 MB model switches real GPIO pins on a Raspberry Pi 4, fully offline, in about 1.4 seconds per command. It is not yet reliable enough to trust on its own, and that is the useful result.
A 29 MB decision model can switch real GPIO pins on a Raspberry Pi 4, fully offline, in about 1.4 seconds per command. It is not yet reliable enough to trust on its own. That second sentence is the useful result of this project.
I built a small home assistant on a Pi 4. Cactus Needle, a tiny tool-calling model, decides what to do. Plain Python switches the pins, keeps notes and enforces the safety rules. A 0.5B Qwen model, running in Ollama, writes summaries when text is needed. I tested two versions against the same 11 commands, and both scripts are included at the end.
| At a glance | Result |
|---|---|
| Hardware | Raspberry Pi 4, 64-bit Raspberry Pi OS, command line only |
| Decision model | Cactus Needle 3 (full 20-layer model, 29 MB) |
| Writer model | Qwen 2.5 0.5B via Ollama |
| Needle latency | 0.8–1.1 s with one tool, about 1.4 s with five tools |
| Needle speed on Pi 4 | Prefill 70–95 tok/s, decode 30–43 tok/s |
| Qwen speed on Pi 4 | 5.4 tok/s |
| Best routing score | 7 of 9 actionable commands correct (v2) |
| Wrong physical actions run automatically | 0 of 22 test commands |
The idea: a controller that decides, and a model that writes
The goal was a small personal assistant that runs entirely on a cheap board, with no cloud and no API keys. It should take a plain sentence, switch something at home, remember what I tell it, and summarize my notes.
Most local-AI demos put one chat model in charge of everything. I split the job in two, the way a control system splits it:
- A decision model turns a sentence into a structured action: which tool, which arguments, and how confident it is. Needle does this and nothing else. It cannot chat or explain.
- A writer model produces text when text is actually needed. Qwen 2.5 0.5B does this. It never touches the hardware.
Between them sits ordinary Python, which plays the role a PLC program plays in a plant. It owns the I/O, checks every proposed action against rules, and decides whether to act. The models propose; the program decides.
I work in industrial automation, so I scoped this POC to the home on purpose. Lights and a fan are low-risk, easy to demo, and still raise the same questions a plant would: who is allowed to switch what, on what evidence, and what happens when the model is wrong.
How it works
Every command passes through three layers, and only the middle one, plain Python, can touch the hardware.

Needle turns the sentence into a proposed tool call with a confidence score. Python checks that score against a threshold and the device name against an allowlist, then switches a pin, appends a note to memory.md, or asks Qwen to summarize the notes. Qwen is just one of the tools: it only runs when a request needs written text.
memory.md holds only what I explicitly ask the assistant to remember. Device state never lives there; it is always read from the pins.
Hardware and software setup
Everything runs on one Raspberry Pi 4 in console mode, reached over SSH. No relays or loads were connected. The scripts drive GPIO17, GPIO22 and GPIO27 as outputs and read each pin back with pinctrl, so every action is verified at the hardware level.
| Component | Version or choice | Why |
|---|---|---|
| OS | Raspberry Pi OS 64-bit (Bookworm), Python 3.11.2 | Needle and Ollama need ARM64 |
| GPIO library | gpiozero 2.0.1 with lgpio | Standard on Raspberry Pi OS |
| Decision model | cactus-needle from pip, full 20-layer weights | Tool calling with a confidence score |
| Writer model | Ollama with qwen2.5:0.5b | About 1.6 GB of RAM was free; larger models did not fit comfortably |
| Memory | memory.md, a plain Markdown file | Readable, editable, easy to keep private |
Two setup details cost me time. The Python virtual environment cannot see the system gpiozero package until include-system-site-packages = true is set in pyvenv.cfg. And Needle's binary sends telemetry by default; setting NEEDLE_TELEMETRY=0 and DO_NOT_TRACK=1 turns it off, as documented on PyPI. Full commands are in Appendix A.
First test: one tool, one pin, one surprise
The first test gave Needle a single tool, set_motor(state), wired to GPIO17. Any call below 0.7 confidence was ignored.
"Turn the motor on" worked at once: correct call, confidence 0.94, pin high in under a second. "Stop the motor" did not. Needle returned state='on' at confidence 0.55, so the gate blocked it. Without the gate, saying "stop" would have started the motor.
Needle's reasoning field explained the failure on the second run: "carries over state 'on' from history". By default, a Needle agent keeps every previous turn in its conversation, and Needle printed a warning saying so. My first "on" command was pulling every later command toward "on". Latency also crept from 0.9 s to 1.5 s as the history grew.
The fix is one argument: needle.Needle(tools=..., stateless=True). The design lesson is bigger. The model must not remember the machine's state. The I/O is the source of truth. Each command is read on its own, and if the device state matters, the code reads the pin.
Version 1: four tools and a confirmation prompt
Version 1 routed 6 of 9 actionable commands correctly, and every wrong device action stopped at a prompt. It gave Needle four tools: set_device(device, state), device_status(device), add_note(text) and summarize_notes(period). Device names were free text, checked against an allowlist in code. At 0.7 confidence or above the action ran; below that, the assistant asked first.
| Command | Needle's call | Conf. | Correct? |
|---|---|---|---|
| turn on the living room light | set_device(living room light, on) | 0.79 | Yes |
| switch off the living room light | set_device(living room light, off) | 0.74 | Yes |
| turn the fan on | set_device(fan, on) | 0.56 | Yes |
| is the bedroom light on | set_device(bedroom light, on) | 0.60 | No: a question read as a command |
| what's on in the house | set_device(living room light, on) | 0.07 | No |
| note that I need to buy milk tomorrow | add_note("I need to buy milk") | 0.46 | No: dropped "tomorrow" |
| remember the plumber comes on Friday at 10 | add_note("plumber comes on Friday at 10") | 0.16 | Yes |
| add a note: renew the car insurance | add_note("renew the car insurance") | 0.55 | Yes |
| summarize today's notes | summarize_notes(today) | 0.90 | Yes |
| turn on the kitchen light (no such device) | set_device(kitchen light, on) | 0.51 | Refused by the allowlist |
| what's the weather (no tool) | device_status(all) | 0.07 | No: wrong tool |
This run was interactive, and it taught me something uncomfortable. I answered "y" to three wrong prompts, so "is the bedroom light on" really did switch the bedroom light on. A confirmation step only protects you if the person reads it. Qwen also invented a date in its summary ("Friday, October 26") that appeared nowhere in my notes.
Version 2: a stricter tool design and a scored test
Version 2 routed 7 of 9 actionable commands correctly but introduced a dangerous new error. It followed Cactus's own tool-design guidance with four changes:
- Fixed device list. The device argument became a closed choice of three names, so the grammar stops Needle from inventing devices. Pin numbers stay in the code only.
- One tool per action.
turn_on(device),turn_off(device)andcheck_device(device)replacedset_deviceanddevice_status. The check tool's description says it never changes anything. - Risk-based thresholds. Switching a device needs 0.7 confidence to run automatically. Checking status and saving notes need 0.4. Below 0.3, nothing runs and nothing is offered.
- Plain-language prompts.
Turn ON the fan? [y/N]instead ofRun it?.
I also added a --test mode that runs the same 11 commands and scores them without switching pins or asking me anything. That removes my own answers from the result.
| Command | Needle's call | Conf. | Decision | Score |
|---|---|---|---|---|
| turn on the living room light | turn_on(living room light) | 0.77 | act | PASS |
| switch off the living room light | turn_on(living room light) | 0.57 | ask | FAIL |
| turn the fan on | turn_on(fan) | 0.67 | ask | ASK |
| is the bedroom light on | check_device(bedroom light) | 0.51 | act | PASS |
| what's on in the house | check_device(living room light) | 0.09 | refuse | FAIL |
| note that I need to buy milk tomorrow | add_note("I need to buy milk tomorrow") | 0.31 | ask | ASK |
| remember the plumber comes on Friday at 10 | add_note("plumber comes on Friday at 10") | 0.04 | refuse | FAIL |
| add a note: renew the car insurance | add_note("renew the car insurance") | 0.36 | ask | ASK |
| summarize today's notes | summarize_notes(today) | 0.89 | act | PASS |
| turn on the kitchen light (no such device) | turn_on(living room light) | 0.49 | ask | ASK |
| what's the weather (no tool) | check_device(living room light) | 0.03 | refuse | PASS |
The totals were PASS 4, ASK 4, FAIL 3, with an average Needle latency of 1,443 ms. PASS means the right action ran without a prompt; ASK means it was right but asked first; FAIL means the wrong tool or arguments, or a correct call refused.
What the numbers say
On this custom toolset, Needle's confidence score does not separate right answers from wrong ones. That matters more than the one-point gain from v1 to v2.
| Measure | v1 | v2 |
|---|---|---|
| Correct tool and arguments (of 9 actionable commands) | 6 | 7 |
| Unknown device ("kitchen light") | Refused by the allowlist | Forced onto the living room light, asked first |
| Wrong physical actions run without a prompt | 0 | 0 |
| Confidence range of correct calls | 0.16–0.90 | 0.04–0.89 |
| Confidence range of wrong calls | 0.07–0.60 | 0.03–0.57 |
| Average Needle latency | 1,429 ms | 1,443 ms |
Five findings stand out:
- One tool per action fixed questions and notes. "Is the bedroom light on" now goes to the read-only tool, and notes keep their full text, including "tomorrow".
- It also broke "switch off". v1 handled it correctly with a
stateargument. v2 choseturn_on, which is the worst possible error for a switch. One change fixed one thing and broke another. - The closed device list removed a safety net. In v1, an unknown device produced an unknown name, which the allowlist caught. In v2, the grammar forced "kitchen light" onto a real device. A constraint that helps accuracy can hide errors.
- No threshold can sort these calls. A correct note scored 0.04 while a wrong "turn on" scored 0.57. Wherever you put the line, you either refuse good commands or offer bad ones.
- The layers still held. Across 22 test commands, no wrong physical action ran automatically. The weak link was the human approving prompts, not the code.
Performance on the Pi 4
Needle on my Pi 4 ran roughly ten times slower than Cactus's published Raspberry Pi 5 figures for the same 20-layer model. Every Needle response reports its own prefill_tps and decode_tps, so these are the model's numbers, not my stopwatch.
| Measure (20-layer Needle) | Pi 4, measured | Pi 5, published by Cactus |
|---|---|---|
| Prefill | 70–95 tok/s | about 1,000 tok/s |
| Decode | 30–43 tok/s | about 400 tok/s |
The Pi 5 figures come from Cactus's supported devices guide; I have not measured a Pi 5 myself.
Latency grew with the number of tools: about 0.9 s with one tool and about 1.4 s with five. Needle reads every tool description on every command, so a longer tool list means a longer prompt. Qwen 2.5 0.5B summarized two short notes at 5.4 tok/s, which is usable for a summary but too slow for conversation.
What safety-critical engineering already knew
Every problem in this project has a known answer in industrial control. The AI parts are new; the failure modes are not.
- The model proposes, the program decides. A PLC program checks interlocks before it energizes an output. Here, Python checks confidence and the allowlist before it touches a pin.
- The I/O is the source of truth. A control system never trusts its memory of a valve's position over the position feedback. The stateless agent and the pin readback follow the same rule.
- Names for the operator, addresses for the program. The model sees "fan". Only the code knows the fan is GPIO22, just as an HMI shows a tag name while the PLC owns the address.
- Thresholds follow consequences. Switching a device needs more confidence than reading its status, the same way a plant applies more rigour to functions that can cause harm.
- Confirmation fatigue is alarm fatigue. I approved three wrong prompts because the assistant asked too often. Alarm management in process plants exists because operators who see too many alerts stop reading them.
- A confidence score is not a safety function. A single channel cannot verify itself. Real assurance needs an independent check, such as a rule that blocks
turn_onwhen the sentence contains "off". - AI is never the stop path. On real hardware, an emergency stop must be a physical button or a hard-wired circuit that does not depend on any model.
Limitations
This is a proof of concept, and the results should be read as a first measurement, not a benchmark.
- Small test set. 11 commands, each run once per version. I have not yet checked whether Needle gives identical results on repeated runs.
- v1 was tested interactively, v2 automatically. The routing results compare fairly; the v1 outcomes also reflect my own answers at the prompts.
- No real loads. The pins switched with nothing connected. Relays, motors and noise were not part of the test.
- English only, typed input. No voice, no other languages, no typos tested.
- Base models only. Needle was used as shipped, with no fine-tuning, at its full 20-layer depth. Qwen's summary quality is based on two short runs.
Conclusion and what comes next
A small controller can already host a useful AI split: a 29 MB decision model that turns sentences into actions, ordinary code that owns the hardware, and a tiny writer model for text. It ran fully offline on a Pi 4, and the safety layers kept every wrong physical action from running on its own. What it cannot do yet is decide reliably by itself, and its confidence score cannot be the safety filter.
The next steps below are assumptions to test, not results:
- Fine-tuning Needle on my own commands should help most. Needle is built to be fine-tuned for one product's tools. Cactus reports gains of 18 to 36 points on a tool-calling benchmark after fine-tuning, in its supported devices guide. I expect better routing on "switch off" and better-calibrated confidence. This is Part 2 of the project.
- A rule-based cross-check should catch the dangerous errors now. Blocking
turn_onwhen the sentence says "off", or when the named device is not in the sentence, would have stopped both risky v2 errors. It is deterministic and independent of the model. - A Raspberry Pi 5 should cut latency sharply. Cactus's published Pi 5 numbers are about ten times my Pi 4 measurements. If that holds, a command would take well under a second even with more tools.
- The AI HAT+ 2 could take over the writer model. The Raspberry Pi AI HAT+ 2 adds a Hailo-10H accelerator (40 TOPS INT4) with 8 GB of its own RAM to a Pi 5, aimed at local LLMs. Running a larger writer model there would leave the Pi's memory free. As far as I can tell, Needle would still run on the CPU, since the HAT runs models compiled for Hailo's toolchain.
- Voice and a weather tool are next on the feature list. Cactus's Whistle speech model runs on the same engine as Needle, and a weather API is one more tool and one function.
The broader point holds even at this scale: the AI is new, but the engineering it needs is not. Interlocks, I/O as the source of truth and independent verification are what make a small model safe to connect to anything real.
Related reading
- AI in safety-critical systems: what IEC-world engineers require: the same boundary, "the model proposes, the program decides", argued for real plants and the standards behind them.
- How the AI agent loop works: what changes when a model is allowed to act in a loop instead of answering once.
- Long-term memory for AI agents:
memory.mdhere is the simplest possible memory; this covers the architecture that replaces it, and what goes wrong with stale facts.
Appendix A: Setup commands
Run these on a Pi 4 with 64-bit Raspberry Pi OS. Nothing needs to be connected to the GPIO header; if you do connect loads, use a relay module and never drive anything directly from a 3.3 V pin.
# 1. Check the OS and GPIO access
uname -m # must print aarch64
ls /dev/gpiochip*
pinctrl set 17 op dh && pinctrl get 17 # expect "hi"
pinctrl set 17 op dl && pinctrl get 17 # expect "lo"
# 2. GPIO library (system packages)
sudo apt install -y python3-gpiozero python3-lgpio
# 3. Python environment for Needle, able to see system packages
python3 -m venv ~/needle-env
sed -i 's/^include-system-site-packages.*/include-system-site-packages = true/' ~/needle-env/pyvenv.cfg
source ~/needle-env/bin/activate
pip install cactus-needle
python -c "import gpiozero, needle; print('OK')"
# 4. Ollama and the writer model
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:0.5b
# 5. Run
mkdir -p ~/home-assistant && cd ~/home-assistant
# save the scripts from Appendix B and C here, then:
python home_assistant.py # v1, interactive
python home_assistant_v2.py --test # v2, scored test
python home_assistant_v2.py # v2, interactiveThe first run downloads Needle's engine and weights, so the Pi needs internet once. Your notes go to memory.md and every turn is logged to log.jsonl; keep both out of any public repository.
Appendix B: Version 1 script (home_assistant.py)
#!/usr/bin/env python3
"""Pi home assistant POC: Needle routes commands, Qwen (Ollama) writes summaries."""
import os
os.environ.setdefault("NEEDLE_TELEMETRY", "0") # Needle's documented opt-out
os.environ.setdefault("DO_NOT_TRACK", "1")
import json
import readline # noqa: F401 (arrow-key history at the prompt)
import subprocess
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Literal
import needle
from gpiozero import OutputDevice
BASE = Path(__file__).resolve().parent
MEMORY = BASE / "memory.md"
LOG = BASE / "log.jsonl"
OLLAMA = "http://localhost:11434"
ACT_THRESHOLD = 0.7
QWEN = None # set at startup from the model installed in Ollama
# ---- Devices: the allowlist. Only these pins can ever be switched. ----
DEVICES = {"living room light": 17, "bedroom light": 27, "fan": 22}
ALIASES = {"living room": "living room light", "lounge light": "living room light",
"bedroom": "bedroom light", "bedroom lamp": "bedroom light",
"ceiling fan": "fan"}
outputs = {name: OutputDevice(pin, initial_value=False) for name, pin in DEVICES.items()}
def resolve(device):
d = device.strip().lower()
if d.startswith("the "):
d = d[4:]
d = ALIASES.get(d, d)
return d if d in DEVICES else None
def level(pin):
out = subprocess.run(["pinctrl", "get", str(pin)], capture_output=True, text=True).stdout
return "hi" if "| hi" in out else ("lo" if "| lo" in out else "?")
def status_line(name):
pin = DEVICES[name]
return f"{name}: {'ON' if outputs[name].value else 'OFF'} (GPIO{pin} reads {level(pin)})"
# ---- Memory file ----
def ensure_memory():
if not MEMORY.exists():
MEMORY.write_text("# Home memory\n\n## Notes\n")
def read_notes():
ensure_memory()
return [line[2:] for line in MEMORY.read_text().splitlines() if line.startswith("- ")]
# ---- Qwen via Ollama ----
def pick_model():
with urllib.request.urlopen(OLLAMA + "/api/tags", timeout=10) as resp:
models = json.load(resp).get("models", [])
return models[0]["name"] if models else None
def ask_qwen(prompt):
payload = {"model": QWEN, "prompt": prompt, "stream": False,
"options": {"num_predict": 160, "num_ctx": 2048, "temperature": 0.2}}
req = urllib.request.Request(OLLAMA + "/api/generate", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as resp:
d = json.load(resp)
return d["response"].strip(), d["eval_count"] / (d["eval_duration"] / 1e9)
# ---- What each tool actually does ----
def do_set_device(device, state):
name = resolve(device)
if name is None:
return f"REFUSED: unknown device '{device}'. Known: {', '.join(DEVICES)}"
if state == "on":
outputs[name].on()
else:
outputs[name].off()
return status_line(name)
def do_device_status(device):
if device.strip().lower() in ("all", "everything", "all devices", "home", "house"):
return "\n ".join(status_line(n) for n in DEVICES)
name = resolve(device)
if name is None:
return f"REFUSED: unknown device '{device}'. Known: {', '.join(DEVICES)}"
return status_line(name)
def do_add_note(text):
text = text.strip()
if not text:
return "REFUSED: empty note"
ensure_memory()
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
with MEMORY.open("a") as f:
f.write(f"- {stamp} | {text}\n")
return f"Saved note: {text}"
def do_summarize_notes(period):
notes = read_notes()
if period == "today":
today = datetime.now().strftime("%Y-%m-%d")
notes = [n for n in notes if n.startswith(today)]
if not notes:
return "No notes to summarize."
if QWEN is None:
return "ERROR: no Ollama model available. Start it with: sudo systemctl start ollama"
print(f" (asking {QWEN} to summarize {len(notes)} notes...)")
prompt = ("These are personal notes, one per line, each with a date. "
"Summarize them in at most 3 short bullet points. "
"Use only what is written in the notes.\n\n" + "\n".join(notes[-30:]))
try:
text, tps = ask_qwen(prompt)
except urllib.error.URLError:
return "ERROR: Ollama not reachable. Start it with: sudo systemctl start ollama"
return f"Summary ({QWEN}, {tps:.1f} tok/s):\n{text}"
IMPL = {"set_device": do_set_device, "device_status": do_device_status,
"add_note": do_add_note, "summarize_notes": do_summarize_notes}
# ---- What Needle sees (the docstring is the description) ----
@needle.tool
def set_device(device: str, state: Literal["on", "off"]):
"Turn a home device on or off. Devices: living room light, bedroom light, fan. on = switch on, start. off = switch off, stop."
return do_set_device(device, state)
@needle.tool
def device_status(device: str):
"Check whether a home device is on or off. Use 'all' to check every device."
return do_device_status(device)
@needle.tool
def add_note(text: str):
"Save a personal note or reminder to memory. text is the note itself, e.g. 'buy milk'."
return do_add_note(text)
@needle.tool
def summarize_notes(period: Literal["today", "all"]):
"Summarize the saved notes, either today's notes or all notes."
return do_summarize_notes(period)
TOOLS = [set_device, device_status, add_note, summarize_notes]
try:
agent = needle.Needle(tools=TOOLS, stateless=True)
STATELESS = True
except TypeError: # older package without stateless: reset every turn instead
agent = needle.Needle(tools=TOOLS)
STATELESS = False
# ---- Main loop ----
def fmt(c):
args = ", ".join(f"{k}={v!r}" for k, v in c.get("arguments", {}).items())
return f"{c['name']}({args})"
def run_calls(calls):
out = []
for c in calls:
fn = IMPL.get(c["name"])
if fn is None:
out.append(f"REFUSED: unknown tool '{c['name']}'")
continue
try:
out.append(fn(**c.get("arguments", {})))
except Exception as e:
out.append(f"ERROR in {c['name']}: {e}")
return out
def handle(text):
if not STATELESS:
agent.reset()
t0 = time.time()
r = agent.complete(text)
ms = (time.time() - t0) * 1000
calls = r.get("function_calls") or []
held = r.get("suppressed_calls") or []
conf = r.get("confidence", 0.0)
shown = calls or held
print(f" [needle {ms:.0f} ms | conf {conf:.2f}] {', '.join(fmt(c) for c in shown) or 'no tool'}")
print(f" reasoning: {r.get('reasoning')}")
decision, results = "none", []
if calls and conf >= ACT_THRESHOLD:
results, decision = run_calls(calls), "executed"
elif shown:
if input(" Not sure. Run it? [y/N] ").strip().lower() == "y":
results, decision = run_calls(shown), "confirmed"
else:
decision = "declined"
else:
print(" I can't do that yet.")
for line in results:
print(" → " + line)
with LOG.open("a") as f:
f.write(json.dumps({"ts": datetime.now().isoformat(timespec="seconds"), "input": text,
"calls": calls, "suppressed": held, "confidence": conf,
"decision": decision, "latency_ms": round(ms),
"prefill_tps": r.get("prefill_tps"),
"decode_tps": r.get("decode_tps")}) + "\n")
def main():
global QWEN
ensure_memory()
try:
QWEN = pick_model()
except urllib.error.URLError:
QWEN = None
print(f"Home assistant POC | Needle stateless={STATELESS} | writer: {QWEN or 'not available'}")
print("Devices: " + ", ".join(f"{n} (GPIO{p})" for n, p in DEVICES.items()))
print(f"Memory: {MEMORY}")
print("Commands: /status /notes /help quit")
try:
while True:
text = input("\nyou> ").strip()
if not text:
continue
if text.lower() in ("quit", "exit"):
break
if text == "/status":
for n in DEVICES:
print(" " + status_line(n))
elif text == "/notes":
print(MEMORY.read_text())
elif text == "/help":
print(" Try: turn on the living room light | is the fan on | "
"note that I need to buy milk | summarize today's notes")
else:
handle(text)
except (KeyboardInterrupt, EOFError):
print()
finally:
for o in outputs.values():
o.off()
o.close()
print("All devices off (safe state), bye")
if __name__ == "__main__":
main()Appendix C: Version 2 script (home_assistant_v2.py)
#!/usr/bin/env python3
"""Pi home assistant POC v2: Needle routes commands, Qwen (Ollama) writes summaries.
v2 changes vs v1:
1. fixed device list in the tool schema (grammar-constrained names)
2. one tool per action: turn_on, turn_off, check_device
3. risk-based confidence thresholds (physical vs read-only)
4. plain-language confirmation prompts
Run: python home_assistant_v2.py interactive
python home_assistant_v2.py --test scored routing test, no actions taken
"""
import os
os.environ.setdefault("NEEDLE_TELEMETRY", "0") # Needle's documented opt-out
os.environ.setdefault("DO_NOT_TRACK", "1")
import json
import readline # noqa: F401 (arrow-key history at the prompt)
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Literal, get_args
import needle
from gpiozero import OutputDevice
VERSION = "v2"
BASE = Path(__file__).resolve().parent
MEMORY = BASE / "memory.md"
LOG = BASE / "log.jsonl"
OLLAMA = "http://localhost:11434"
QWEN = None # set at startup from the model installed in Ollama
# ---- Devices: names are for Needle, pin numbers are for the code only ----
DEVICES = {"living room light": 17, "bedroom light": 27, "fan": 22}
Device = Literal["living room light", "bedroom light", "fan"]
DeviceOrAll = Literal["living room light", "bedroom light", "fan", "all"]
assert set(get_args(Device)) == set(DEVICES), "Device list and DEVICES must match"
outputs = {name: OutputDevice(pin, initial_value=False) for name, pin in DEVICES.items()}
# ---- Risk-based thresholds: stricter when something physical happens ----
ACT_AT = {"turn_on": 0.7, "turn_off": 0.7,
"check_device": 0.4, "add_note": 0.4, "summarize_notes": 0.4}
FLOOR = 0.3 # below this: don't act and don't ask
def level(pin):
out = subprocess.run(["pinctrl", "get", str(pin)], capture_output=True, text=True).stdout
return "hi" if "| hi" in out else ("lo" if "| lo" in out else "?")
def status_line(name):
pin = DEVICES[name]
return f"{name}: {'ON' if outputs[name].value else 'OFF'} (GPIO{pin} reads {level(pin)})"
# ---- Memory file ----
def ensure_memory():
if not MEMORY.exists():
MEMORY.write_text("# Home memory\n\n## Notes\n")
def read_notes():
ensure_memory()
return [line[2:] for line in MEMORY.read_text().splitlines() if line.startswith("- ")]
# ---- Qwen via Ollama ----
def pick_model():
with urllib.request.urlopen(OLLAMA + "/api/tags", timeout=10) as resp:
models = json.load(resp).get("models", [])
return models[0]["name"] if models else None
def ask_qwen(prompt):
payload = {"model": QWEN, "prompt": prompt, "stream": False,
"options": {"num_predict": 160, "num_ctx": 2048, "temperature": 0.2}}
req = urllib.request.Request(OLLAMA + "/api/generate", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as resp:
d = json.load(resp)
return d["response"].strip(), d["eval_count"] / (d["eval_duration"] / 1e9)
# ---- What each tool actually does ----
def switch(device, on):
if device not in DEVICES: # second safety check after the grammar
return f"REFUSED: unknown device '{device}'"
if on:
outputs[device].on()
else:
outputs[device].off()
return status_line(device)
def do_turn_on(device):
return switch(device, True)
def do_turn_off(device):
return switch(device, False)
def do_check_device(device):
if device == "all":
return "\n ".join(status_line(n) for n in DEVICES)
if device not in DEVICES:
return f"REFUSED: unknown device '{device}'"
return status_line(device)
def do_add_note(text):
text = text.strip()
if not text:
return "REFUSED: empty note"
ensure_memory()
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
with MEMORY.open("a") as f:
f.write(f"- {stamp} | {text}\n")
return f"Saved note: {text}"
def do_summarize_notes(period):
notes = read_notes()
if period == "today":
today = datetime.now().strftime("%Y-%m-%d")
notes = [n for n in notes if n.startswith(today)]
if not notes:
return "No notes to summarize."
if QWEN is None:
return "ERROR: no Ollama model available. Start it with: sudo systemctl start ollama"
texts = [n.split(" | ", 1)[-1] for n in notes[-30:]] # Qwen sees the notes, not timestamps
print(f" (asking {QWEN} to summarize {len(texts)} notes...)")
prompt = ("Summarize these personal notes in at most 3 short bullet points. "
"Use only the facts in the notes. Do not add dates, names or details.\n\n"
+ "\n".join(f"- {t}" for t in texts))
try:
text, tps = ask_qwen(prompt)
except urllib.error.URLError:
return "ERROR: Ollama not reachable. Start it with: sudo systemctl start ollama"
return f"Summary ({QWEN}, {tps:.1f} tok/s):\n{text}"
IMPL = {"turn_on": do_turn_on, "turn_off": do_turn_off, "check_device": do_check_device,
"add_note": do_add_note, "summarize_notes": do_summarize_notes}
# ---- What Needle sees (the docstring is the description) ----
@needle.tool
def turn_on(device: Device):
"Switch a home device on. For commands like 'turn on the fan', 'start the fan', 'bedroom light on'."
return do_turn_on(device)
@needle.tool
def turn_off(device: Device):
"Switch a home device off. For commands like 'turn off the fan', 'stop the fan', 'bedroom light off'."
return do_turn_off(device)
@needle.tool
def check_device(device: DeviceOrAll):
"Report whether devices are on or off; never changes anything. For questions like 'is the fan on?' or 'what's on in the house?'. Use 'all' for every device."
return do_check_device(device)
@needle.tool
def add_note(text: str):
"Save a personal note or reminder to memory. text is the whole note including any day or time, e.g. 'buy milk tomorrow'."
return do_add_note(text)
@needle.tool
def summarize_notes(period: Literal["today", "all"]):
"Summarize the saved notes, either today's notes or all notes."
return do_summarize_notes(period)
TOOLS = [turn_on, turn_off, check_device, add_note, summarize_notes]
try:
agent = needle.Needle(tools=TOOLS, stateless=True)
STATELESS = True
except TypeError: # older package without stateless: reset every turn instead
agent = needle.Needle(tools=TOOLS)
STATELESS = False
# ---- Decision logic ----
def fmt(c):
args = ", ".join(f"{k}={v!r}" for k, v in c.get("arguments", {}).items())
return f"{c['name']}({args})"
def describe(c):
a = c.get("arguments", {})
n = c["name"]
if n == "turn_on":
return f"Turn ON the {a.get('device')}?"
if n == "turn_off":
return f"Turn OFF the {a.get('device')}?"
if n == "check_device":
return "Check all devices?" if a.get("device") == "all" else f"Check the {a.get('device')}?"
if n == "add_note":
return f"Save note \"{a.get('text')}\"?"
if n == "summarize_notes":
return f"Summarize {a.get('period')} notes?"
return f"Run {fmt(c)}?"
def ask_needle(text):
if not STATELESS:
agent.reset()
t0 = time.time()
r = agent.complete(text)
return r, (time.time() - t0) * 1000
def decide(r):
calls = r.get("function_calls") or []
held = r.get("suppressed_calls") or []
conf = r.get("confidence", 0.0)
cands = calls or held
if not cands or conf < FLOOR:
return [], [], "refuse", cands
auto = [c for c in calls if conf >= ACT_AT.get(c["name"], 0.7)]
ask = [c for c in cands if c not in auto]
label = "act" if not ask else ("ask" if not auto else "mixed")
return auto, ask, label, cands
def run_call(c):
fn = IMPL.get(c["name"])
if fn is None:
return f"REFUSED: unknown tool '{c['name']}'"
try:
return fn(**c.get("arguments", {}))
except Exception as e:
return f"ERROR in {c['name']}: {e}"
def log_turn(text, r, ms, label, mode, executed=(), result=None):
entry = {"ts": datetime.now().isoformat(timespec="seconds"), "version": VERSION,
"mode": mode, "input": text, "calls": r.get("function_calls") or [],
"suppressed": r.get("suppressed_calls") or [], "confidence": r.get("confidence", 0.0),
"decision": label, "executed": [fmt(c) for c in executed], "test_result": result,
"latency_ms": round(ms), "prefill_tps": r.get("prefill_tps"),
"decode_tps": r.get("decode_tps")}
with LOG.open("a") as f:
f.write(json.dumps(entry, default=str) + "\n")
# ---- Interactive mode ----
def handle(text):
r, ms = ask_needle(text)
auto, ask, label, cands = decide(r)
print(f" [needle {ms:.0f} ms | conf {r.get('confidence', 0.0):.2f}] "
f"{', '.join(fmt(c) for c in cands) or 'no tool'}")
print(f" reasoning: {r.get('reasoning')}")
done = []
if label == "refuse":
print(" I can't do that yet." if not cands else " Sorry, I'm not sure what you mean.")
for c in auto:
done.append(c)
print(" → " + run_call(c))
for c in ask:
if input(f" {describe(c)} [y/N] ").strip().lower() == "y":
done.append(c)
print(" → " + run_call(c))
else:
print(" → skipped")
log_turn(text, r, ms, label, "live", executed=done)
def repl():
print("Devices: " + ", ".join(f"{n} (GPIO{p})" for n, p in DEVICES.items()))
print(f"Memory: {MEMORY}")
print("Commands: /status /notes /help quit")
while True:
text = input("\nyou> ").strip()
if not text:
continue
if text.lower() in ("quit", "exit"):
break
if text == "/status":
for n in DEVICES:
print(" " + status_line(n))
elif text == "/notes":
print(MEMORY.read_text())
elif text == "/help":
print(" Try: turn on the fan | is the bedroom light on | "
"note that I need to buy milk tomorrow | summarize today's notes")
else:
handle(text)
# ---- Test mode: same 11 commands as the v1 run; nothing is executed ----
TESTS = [
("turn on the living room light", "turn_on", {"device": "living room light"}),
("switch off the living room light", "turn_off", {"device": "living room light"}),
("turn the fan on", "turn_on", {"device": "fan"}),
("is the bedroom light on", "check_device", {"device": "bedroom light"}),
("what's on in the house", "check_device", {"device": "all"}),
("note that I need to buy milk tomorrow", "add_note", {"text": ["milk", "tomorrow"]}),
("remember the plumber comes on Friday at 10", "add_note", {"text": ["plumber", "friday"]}),
("add a note: renew the car insurance", "add_note", {"text": ["insurance"]}),
("summarize today's notes", "summarize_notes", {"period": "today"}),
("turn on the kitchen light", None, {}), # unknown device: must NOT act
("what's the weather", None, {}), # no tool yet: must NOT act
]
def matches(c, tool, exp):
if c["name"] != tool:
return False
args = c.get("arguments", {})
for k, v in exp.items():
got = str(args.get(k, "")).lower()
if isinstance(v, list):
if not all(w in got for w in v):
return False
elif got != v:
return False
return True
def run_tests():
print(f"\n{'command':<38} {'needle call':<44} {'conf':>5} {'decide':<6} result")
print("-" * 104)
counts, lat = {"PASS": 0, "ASK": 0, "FAIL": 0}, []
for text, tool, exp in TESTS:
r, ms = ask_needle(text)
lat.append(ms)
auto, ask, label, cands = decide(r)
if tool is None:
result = "FAIL" if auto else ("ASK" if ask else "PASS")
else:
correct = bool(cands) and all(matches(c, tool, exp) for c in cands)
result = "FAIL" if not correct or label == "refuse" else ("PASS" if label == "act" else "ASK")
counts[result] += 1
shown = ", ".join(fmt(c) for c in cands) or "-"
print(f"{text[:38]:<38} {shown[:44]:<44} {r.get('confidence', 0.0):>5.2f} {label:<6} {result}")
log_turn(text, r, ms, label, "test", result=result)
n = len(TESTS)
print("-" * 104)
print(f"PASS {counts['PASS']}/{n} | ASK {counts['ASK']}/{n} | FAIL {counts['FAIL']}/{n} | "
f"avg Needle latency {sum(lat) / n:.0f} ms")
print("PASS = right action, no prompt | ASK = right action, asks first | "
"FAIL = wrong tool or arguments, or acted when it should not")
def main():
global QWEN
ensure_memory()
try:
QWEN = pick_model()
except urllib.error.URLError:
QWEN = None
print(f"Home assistant POC {VERSION} | Needle stateless={STATELESS} | writer: {QWEN or 'not available'}")
try:
if "--test" in sys.argv:
run_tests()
else:
repl()
except (KeyboardInterrupt, EOFError):
print()
finally:
for o in outputs.values():
o.off()
o.close()
print("All devices off (safe state), bye")
if __name__ == "__main__":
main()Appendix D: Terminal screenshots from the test runs
These are the raw terminal outputs behind the results above, captured over SSH on the Pi 4.
GPIO check. Python drives GPIO17 high and low through gpiozero, and pinctrl confirms the real pin level each time.

Version 1, interactive run. Every command with Needle's call, confidence and reasoning, including the three wrong prompts I approved and Qwen's summary.

Version 2, scored test. The --test run of the same 11 commands: PASS 4, ASK 4, FAIL 3, average Needle latency 1,443 ms.

Sources
- Cactus Needle model page
- What Devices Are Supported on Needle (Pi 5 speeds, fine-tuning gains)
- cactus-needle on PyPI (telemetry opt-out)
- Raspberry Pi AI HAT+ 2
Written by Usman Nasir — control systems engineer, Stockholm.