An AI Check for the Dog Every Time the Garage Closes
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
- 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.
- The Pi waits about 3 seconds, then grabs one frame from a Tapo camera in the garage over RTSP using OpenCV.
- It sends that frame, base64 encoded, to the Perplexity Agent API with a prompt asking whether the dog is visible, answer YES or NO.
- 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.
- 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 verifyfixed 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-headlessinto 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.