All articles
Agentic AI13 min

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 glanceResult
HardwareRaspberry Pi 4, 64-bit Raspberry Pi OS, command line only
Decision modelCactus Needle 3 (full 20-layer model, 29 MB)
Writer modelQwen 2.5 0.5B via Ollama
Needle latency0.8–1.1 s with one tool, about 1.4 s with five tools
Needle speed on Pi 4Prefill 70–95 tok/s, decode 30–43 tok/s
Qwen speed on Pi 45.4 tok/s
Best routing score7 of 9 actionable commands correct (v2)
Wrong physical actions run automatically0 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 proposes, Python decides and owns the pins. A command typed over SSH goes to Needle, the decision model, which picks a tool and arguments and returns a confidence score. The Python control layer applies a confidence gate and an allowlist and reads the real pin state. Only if the gate allows does it switch the GPIO outputs on GPIO17, 27 and 22, append a line to memory.md, or call the Qwen 2.5 0.5B writer, which only summarizes notes and never touches the pins. Every turn is printed and logged to log.jsonl, and pins are read back after each switch.

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.

ComponentVersion or choiceWhy
OSRaspberry Pi OS 64-bit (Bookworm), Python 3.11.2Needle and Ollama need ARM64
GPIO librarygpiozero 2.0.1 with lgpioStandard on Raspberry Pi OS
Decision modelcactus-needle from pip, full 20-layer weightsTool calling with a confidence score
Writer modelOllama with qwen2.5:0.5bAbout 1.6 GB of RAM was free; larger models did not fit comfortably
Memorymemory.md, a plain Markdown fileReadable, 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.

CommandNeedle's callConf.Correct?
turn on the living room lightset_device(living room light, on)0.79Yes
switch off the living room lightset_device(living room light, off)0.74Yes
turn the fan onset_device(fan, on)0.56Yes
is the bedroom light onset_device(bedroom light, on)0.60No: a question read as a command
what's on in the houseset_device(living room light, on)0.07No
note that I need to buy milk tomorrowadd_note("I need to buy milk")0.46No: dropped "tomorrow"
remember the plumber comes on Friday at 10add_note("plumber comes on Friday at 10")0.16Yes
add a note: renew the car insuranceadd_note("renew the car insurance")0.55Yes
summarize today's notessummarize_notes(today)0.90Yes
turn on the kitchen light (no such device)set_device(kitchen light, on)0.51Refused by the allowlist
what's the weather (no tool)device_status(all)0.07No: 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:

  1. 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.
  2. One tool per action. turn_on(device), turn_off(device) and check_device(device) replaced set_device and device_status. The check tool's description says it never changes anything.
  3. 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.
  4. Plain-language prompts. Turn ON the fan? [y/N] instead of Run 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.

CommandNeedle's callConf.DecisionScore
turn on the living room lightturn_on(living room light)0.77actPASS
switch off the living room lightturn_on(living room light)0.57askFAIL
turn the fan onturn_on(fan)0.67askASK
is the bedroom light oncheck_device(bedroom light)0.51actPASS
what's on in the housecheck_device(living room light)0.09refuseFAIL
note that I need to buy milk tomorrowadd_note("I need to buy milk tomorrow")0.31askASK
remember the plumber comes on Friday at 10add_note("plumber comes on Friday at 10")0.04refuseFAIL
add a note: renew the car insuranceadd_note("renew the car insurance")0.36askASK
summarize today's notessummarize_notes(today)0.89actPASS
turn on the kitchen light (no such device)turn_on(living room light)0.49askASK
what's the weather (no tool)check_device(living room light)0.03refusePASS

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.

Measurev1v2
Correct tool and arguments (of 9 actionable commands)67
Unknown device ("kitchen light")Refused by the allowlistForced onto the living room light, asked first
Wrong physical actions run without a prompt00
Confidence range of correct calls0.16–0.900.04–0.89
Confidence range of wrong calls0.07–0.600.03–0.57
Average Needle latency1,429 ms1,443 ms

Five findings stand out:

  1. 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".
  2. It also broke "switch off". v1 handled it correctly with a state argument. v2 chose turn_on, which is the worst possible error for a switch. One change fixed one thing and broke another.
  3. 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.
  4. 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.
  5. 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, measuredPi 5, published by Cactus
Prefill70–95 tok/sabout 1,000 tok/s
Decode30–43 tok/sabout 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_on when 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:

  1. 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.
  2. A rule-based cross-check should catch the dangerous errors now. Blocking turn_on when 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.
  3. 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.
  4. 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.
  5. 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

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.

bash
# 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, interactive

The 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)

python
#!/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)

python
#!/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.

Terminal output of a GPIO test on a Raspberry Pi 4: a short Python script uses gpiozero to set GPIO17 on and off, and pinctrl reports hi and lo each time, confirming the real pin level.

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

Terminal output of the version 1 interactive run: each command with Needle's tool call, confidence and reasoning, the prompts that were approved, and Qwen's two-bullet summary of the saved notes.

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

Terminal output of the version 2 scored test: eleven commands with Needle's call, confidence, the gate's decision and the PASS, ASK or FAIL result, ending with PASS 4/11, ASK 4/11, FAIL 3/11 and an average Needle latency of 1443 ms.

Sources

Written by Usman Nasir — control systems engineer, Stockholm.