Home-presence detector from list of phones
851
Home presence detector — monitors a list of phones on the local network and sends ntfy push notifications on arrivals and departures.
WhoIsHere answers a simple question: is anyone home?
It periodically scans the static IP addresses of tracked phones on the LAN, maintains a presence state (present / absent / unknown) for each phone, derives the house state (occupied / empty / unknown) from those, and publishes push notifications to an ntfy.sh topic on every transition.
Example notifications:
For each phone, on every scan cycle:
ping -c 1 -W <timeout> <ip> via subprocess. Fast, but phones in deep sleep often block pings.ip neigh show <ip>: if the phone has a valid ARP entry in the host's table (REACHABLE, STALE, DELAY…), it is considered present. This covers phones associated with the Wi-Fi AP that silently drop pings.To avoid false alerts (one missed ping ≠ a departure), each phone must accumulate N consecutive detections to flip to present, and M consecutive misses to flip to absent. Defaults: N=1 (fast arrival), M=3 (slow departure).
Detection is IP-only, not by hostname. You must configure static DHCP reservations on your router for each tracked phone.
On launch, all phones start as unknown. They settle into present or absent after the first scan cycles (typically 1–3 minutes depending on SCAN_INTERVAL_SECONDS and ABSENCE_CONFIRMATIONS).
config/config.yamlcp config/config.yaml.example config/config.yaml
phones:
- phoneName: Anne-S25-Edge # Display name used in notifications
phoneIp: 192.168.1.42 # Static IP reserved on your router
notifyWhenLeaving: true # Send a per-phone ntfy notification on departure/return
- phoneName: Herve-S24-Ultra
phoneIp: 192.168.1.43
# notifyWhenLeaving absent = false by default
| Field | Required | Description |
|---|---|---|
phoneName | yes | Free-form name shown in notifications. Internal ID is its slug (anne-s25-edge). |
phoneIp | yes | LAN IP, must be statically reserved on your router. |
notifyWhenLeaving | no (default false) | If true, a ntfy notification is sent on each individual departure/return of this phone. |
House-level transitions (occupied ↔ empty) are always notified, regardless of
notifyWhenLeaving.
.envcp .env.example .env
# ── ntfy notifications ──────────────────────────────────────────────────
# Both must be non-empty to enable notifications.
# If either is missing or empty, ntfy is silently disabled.
NTFY_BASE_URL=https://ntfy.sh
NTFY_TOPIC=my-secret-topic # Keep this hard to guess
NTFY_USERNAME= # Optional: ntfy auth
NTFY_PASSWORD=
# ── API security ────────────────────────────────────────────────────────
# Required on GET /, GET /phones/{id} and POST /test-notify.
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
# If unset, protected endpoints return 503.
API_TOKEN=
# ── Detection ───────────────────────────────────────────────────────────
SCAN_INTERVAL_SECONDS=60 # Seconds between scan cycles
PRESENCE_CONFIRMATIONS=1 # Consecutive hits to flip to "present"
ABSENCE_CONFIRMATIONS=3 # Consecutive misses to flip to "absent"
PING_TIMEOUT_SECONDS=2 # Per-phone ping timeout
# ── HTTP server ─────────────────────────────────────────────────────────
HTTP_PORT=8000
# ── Config path ─────────────────────────────────────────────────────────
# PHONES_CONFIG_PATH=config/config.yaml # Uncomment to override
Never commit
.env— it is in.gitignore.
docker-compose.yml references the published image hthouzard/whoishere:<version> from Docker Hub — that's what runs on the Raspberry Pi in production. For development, copy the override file so the image is rebuilt locally on every change:
# Linux / macOS
cp docker-compose.override.yml.example docker-compose.override.yml
# Windows PowerShell 7
Copy-Item docker-compose.override.yml.example docker-compose.override.yml
The docker-compose.override.yml file is auto-loaded by Docker Compose, adds build: . plus pull_policy: build (so dev never pulls from Hub), and is gitignored. Do not create this file on the Raspberry Pi — it must use the published image.
docker compose up -d
docker compose logs -f
docker compose down
# Linux / macOS
./scripts/rebuild.sh
# Windows PowerShell 7
./scripts/rebuild.ps1
These scripts chain down → build → up -d → logs -f automatically.
On the Pi, do not create docker-compose.override.yml. To publish then deploy a new version:
# 1. On the dev machine: multi-arch build + push to Docker Hub
./scripts/publish.ps1 -Tag 1.1.0
# 2. Update the tag in docker-compose.yml (1.0.0 → 1.1.0), commit, push
# 3. On the Raspberry Pi
git pull # picks up the new tag in docker-compose.yml
docker compose pull # pulls hthouzard/whoishere:1.1.0 from Docker Hub
docker compose up -d # restarts with the new image
docker compose logs -f # verify scan and notifications
docker ps
# or
docker inspect --format='{{.State.Health.Status}}' whoishere
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
All protected endpoints require the header:
Authorization: Bearer <API_TOKEN>
GET /healthz — publicLiveness probe. Used by the Docker healthcheck. No authentication required.
{ "status": "ok" }
GET / — protectedFull house and phone status.
{
"house_occupied": true,
"house_label": "Occupée",
"occupants_count": 2,
"updated_at": "2026-05-10T14:23:01.456789Z",
"phones": [
{
"id": "anne-s25-edge",
"name": "Anne-S25-Edge",
"ip": "192.168.1.42",
"present": true,
"label": "Présent",
"last_seen": "2026-05-10T14:23:01.123456Z",
"last_checked": "2026-05-10T14:23:01.123456Z"
},
{
"id": "herve-s24-ultra",
"name": "Herve-S24-Ultra",
"ip": "192.168.1.43",
"present": false,
"label": "Absent",
"last_seen": "2026-05-10T12:01:05.654321Z",
"last_checked": "2026-05-10T14:23:01.123456Z"
}
]
}
| Field | Type | Possible values |
|---|---|---|
house_occupied | bool | — |
house_label | string | "Occupée" · "Vide" · "Inconnue" |
occupants_count | int | Number of phones in present state |
updated_at | ISO 8601 UTC datetime | — |
phones[].id | string | Slug of phoneName (e.g. anne-s25-edge) |
phones[].label | string | "Présent" · "Absent" · "Inconnu" |
phones[].last_seen | datetime or null | Last positive detection |
phones[].last_checked | datetime or null | Last scan attempt |
GET /phones/{phone_id} — protectedStatus of a single phone. phone_id is the slug of phoneName (lowercase, non-alphanumeric characters replaced by -).
Example: phoneName: Anne-S25-Edge → phone_id: anne-s25-edge
{
"id": "anne-s25-edge",
"name": "Anne-S25-Edge",
"ip": "192.168.1.42",
"present": true,
"label": "Présent",
"last_seen": "2026-05-10T14:23:01.123456Z",
"last_checked": "2026-05-10T14:23:01.123456Z"
}
Returns 404 if the id is unknown.
POST /test-notify — protectedSends a test notification to the configured ntfy topic. Useful for verifying ntfy credentials.
{ "sent": true }
sent: false if ntfy is not configured (empty topic or URL) or if the request failed.
| Code | Reason |
|---|---|
401 | Missing or invalid token |
404 | Unknown phone_id |
503 | API_TOKEN not set in .env |
Examples use shell variables
$TOKENfor the API token and$HOSTfor the Raspberry Pi address.
export HOST=192.168.1.10 # Raspberry Pi address
export TOKEN=my-api-token
curl -s http://$HOST:8000/healthz | jq .
curl -s http://$HOST:8000/ \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s http://$HOST:8000/ \
-H "Authorization: Bearer $TOKEN" | jq .house_label
curl -s http://$HOST:8000/ \
-H "Authorization: Bearer $TOKEN" \
| jq '[.phones[] | select(.present == true) | {name, last_seen}]'
curl -s http://$HOST:8000/phones/anne-s25-edge \
-H "Authorization: Bearer $TOKEN" | jq .
curl -s -X POST http://$HOST:8000/test-notify \
-H "Authorization: Bearer $TOKEN" | jq .
#!/usr/bin/env bash
STATUS=$(curl -s http://$HOST:8000/ -H "Authorization: Bearer $TOKEN")
echo "House : $(echo $STATUS | jq -r .house_label)"
echo "Present: $(echo $STATUS | jq -r .occupants_count)"
echo $STATUS | jq -r '.phones[] | " \(.name): \(.label)"'
https://ntfy.sh/<your-topic>.Choose a hard-to-guess topic name (or use a private ntfy instance): anyone who knows the topic can read the notifications.
| Event | Condition |
|---|---|
| "Departure of <Name>" | notifyWhenLeaving: true + phone → absent |
| "Return of <Name>" | notifyWhenLeaving: true + phone → present |
| "House empty" | house → empty (always sent) |
| "Someone is home" | house empty → occupied (always sent) |
Homepage supports custom API widgets via customapi.
In Homepage's .env (or docker-compose.yml):
HOMEPAGE_VAR_WHOISHERE_TOKEN=my-api-token
services.yaml- Home:
- WhoIsHere:
icon: mdi-home-account
href: http://192.168.1.10:8000/
description: Home presence
widget:
type: customapi
url: http://192.168.1.10:8000/
headers:
Authorization: "Bearer {{HOMEPAGE_VAR_WHOISHERE_TOKEN}}"
mappings:
- field: house_label
label: House
format: text
- field: occupants_count
label: Present
format: number
This widget displays two metrics: house status ("Occupée" / "Vide" / "Inconnue") and the number of people detected.
| Component | Technology | Role |
|---|---|---|
| HTTP API | FastAPI 0.136 | REST endpoint exposure |
| ASGI server | Uvicorn 0.46 | Async server |
| Config validation | Pydantic 2.13 + pydantic-settings 2.14 | .env loading and validation |
| HTTP client | httpx 0.28 | ntfy notification delivery |
| Phone config | PyYAML 6.0 | config/config.yaml parsing |
| Network detection | system ping + ip neigh (iproute2) | ICMP probe + ARP fallback |
| Containerization | Docker + Docker Compose | Deployment |
| Base image | python:3.12-slim | — |
| Notifications | ntfy.sh | Mobile push |
| Target deployment | Raspberry Pi 4 (Raspbian) | — |
network_mode: host is routed through the WSL2 or HyperKit VM. The container may not see all LAN devices. Reliable operation is on native Linux (Raspbian) only.present → absent transition even though the phone is home and connected to Wi-Fi. Increasing ABSENCE_CONFIRMATIONS mitigates this.unknown for a few scan cycles.Content type
Image
Digest
sha256:aa990a295…
Size
60.2 MB
Last updated
5 months ago
docker pull hthouzard/whoishere