From the blog

An AI Check for the Dog Every Time the Garage Closes

By The Local Home · · 5 min read

#garage#hubitat#raspberry-pi#notifications#ai

In the ratgdo post I said the dog camera had an AI setup behind it that needed its own post. This is that post.

Whenever the garage closes, I get a notification on my phone with a snapshot of where the dog sleeps and an answer to one question: is the dog in the garage or not. My goal is for things to be available where I use it, and for this, that’s a phone notification.

This isn’t fully local. The yes or no comes from a paid cloud AI API. Everything else runs on my network.

How it works

  1. A Hubitat Rule Machine rule triggers when the garage door contact changes to closed. Its only action is one HTTP GET to a small web service on a Raspberry Pi.
  2. The Pi waits about 3 seconds, then grabs one frame from a Tapo camera in the garage over RTSP using OpenCV.
  3. It sends that frame, base64 encoded, to the Perplexity Agent API with a prompt asking whether the dog is visible, answer YES or NO.
  4. It pushes an ntfy notification to my iPhone with the snapshot attached and a title saying whether the dog is in the garage. If anything fails, it sends an error notification instead.
  5. The service runs under systemd so it starts on boot.

I built it with an AI chat assistant writing the code while I ran it on the Pi and pasted back the errors. At one point it suggested ngrok to give the API a public URL for the image. I said no, that adds complexity, and we sent the image inline as base64 instead.

The first version had Hubitat do more of the work: call the Pi, wait, check a virtual switch the Pi set, then notify. Hubitat said the dog was in the garage every time. The check ran before the Pi finished, and the switch was still on from the last run. Turning the switch off first fixed it. After that I asked for the easiest and most rock solid design, and it changed to the Pi sending the notification with the image itself and Hubitat doing just the one GET. I ended up creating a Home button that pushes a GET to kick off the whole dog check.

The code

This is a sanitized version. Placeholders are in angle brackets.

The Hubitat rule:

Trigger:  Garage door contact changes to closed
Action:   Send HTTP GET Request
          http://<pi-ip>:5051/garage-closed?token=<webhook-token>

Use “Send HTTP GET Request,” not the local endpoint action. I picked the wrong one first and got a token error.

The .env file in ~/garage-dog:

TAPO_IP=<camera-ip>
TAPO_USER=<camera-account-username>
TAPO_PASS='<camera-account-password>'
PERPLEXITY_API_KEY=<your-api-key>
PPLX_MODEL=<vision-capable-model>
NTFY_URL=https://ntfy.sh
NTFY_TOPIC=<long-random-topic>
WEBHOOK_TOKEN=<long-random-token>
CAPTURE_DELAY_SECONDS=3

The Python service, trimmed to the important parts:

import base64, os, time
import cv2, requests
from flask import Flask, request, abort

app = Flask(__name__)
E = os.environ
RTSP_URL = f"rtsp://{E['TAPO_USER']}:{E['TAPO_PASS']}@{E['TAPO_IP']}:554/stream1"

def capture_snapshot(path="/tmp/garage.jpg"):
    time.sleep(float(E.get("CAPTURE_DELAY_SECONDS", 3)))
    cap = cv2.VideoCapture(RTSP_URL)
    ok, frame = cap.read()
    cap.release()
    if not ok:
        raise RuntimeError("No frame from camera")
    frame = cv2.resize(frame, (1280, 720))
    cv2.imwrite(path, frame, [cv2.IMWRITE_JPEG_QUALITY, 72])
    return path

def dog_present(path):
    b64 = base64.b64encode(open(path, "rb").read()).decode()
    payload = {
        "model": E["PPLX_MODEL"],
        "input": [{"role": "user", "content": [
            {"type": "input_text", "text": "Look only at this garage-camera image. "
                "Is a dog visibly present in the garage? Reply with exactly one word: YES or NO."},
            {"type": "input_image", "image_url": f"data:image/jpeg;base64,{b64}"},
        ]}],
    }
    r = requests.post("https://api.perplexity.ai/v1/agent",
                      headers={"Authorization": f"Bearer {E['PERPLEXITY_API_KEY']}"},
                      json=payload, timeout=60)
    r.raise_for_status()
    data = r.json()
    text = data.get("output_text") or "".join(
        part.get("text", "") for item in data.get("output", [])
        for part in item.get("content", []) or [])
    return text.strip().upper().startswith("YES")

def notify(dog, path):
    title = "The dog is in the garage" if dog else "The dog is not in the garage"
    requests.put(f"{E['NTFY_URL']}/{E['NTFY_TOPIC']}", data=open(path, "rb"),
                 headers={"Filename": "garage-snapshot.jpg", "Title": title,
                          "Tags": "dog", "Content-Type": "image/jpeg"}, timeout=30)

@app.route("/garage-closed", methods=["GET", "POST"])
def garage_closed():
    if request.args.get("token") != E["WEBHOOK_TOKEN"]:
        abort(403)
    path = capture_snapshot()
    notify(dog_present(path), path)
    return "ok"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5051)

The real one also retries the camera, logs everything, and sends an ntfy error notification if a step fails. It runs in a venv with flask, requests, and opencv-python-headless installed.

The systemd unit, /etc/systemd/system/garage-dog.service:

[Unit]
Description=Garage dog check
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=<your-user>
WorkingDirectory=/home/<your-user>/garage-dog
EnvironmentFile=/home/<your-user>/garage-dog/.env
ExecStart=/home/<your-user>/garage-dog/venv/bin/python3 /home/<your-user>/garage-dog/garage_dog.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Then sudo systemctl daemon-reload and sudo systemctl enable --now garage-dog.

Things that bit me

  • Lookalike characters. My webhook token had Cyrillic letters in it that look exactly like Latin ones, so it never matched. Deleting it and retyping it fixed it. Also no spaces around = in .env.
  • The systemd file got pasted as one long line and systemd rejected it. Writing it line by line and checking it with systemd-analyze verify fixed it.
  • The camera password had a ! in it, which bash treats specially. I spotted that one. Single quotes around the value fixed it.
  • The service’s venv didn’t have OpenCV until I installed opencv-python-headless into it.
  • Hubitat’s timing, described above. Letting the Pi do the waiting and the notifying made it much more reliable.

Security

Keep the API key, webhook token, and camera password in .env, not in the script. If you paste any of them into a chat, a forum post, or a screenshot, rotate them. The ntfy topic is effectively a password, since anyone who knows it can read your notifications and snapshots, so make it long and random. The Pi’s endpoint should only be reachable on your home network.