Sign inSign up

hthouzard/whoishere

By hthouzard

•Updated 5 months ago

Home-presence detector from list of phones

Image
0

851

hthouzard/whoishere repository overview

⁠WhoIsHere

Home presence detector — monitors a list of phones on the local network and sends ntfy⁠ push notifications on arrivals and departures.


⁠Table of contents (EN)

  1. What does it do?⁠
  2. How does it work?⁠
  3. Prerequisites⁠
  4. Configuration⁠
  5. Start, stop, restart⁠
  6. API — Endpoints and JSON responses⁠
  7. curl examples⁠
  8. ntfy notifications⁠
  9. Homepage widget⁠
  10. Tech stack⁠
  11. Known limitations⁠

⁠1 — What does it do?

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:

  • "Anne has left" when her phone disappears from the network.
  • "Someone is home" when the house goes from empty to occupied.
  • "House empty" when the last phone leaves the network.

⁠2 — How does it work?

⁠Two-tier detection

For each phone, on every scan cycle:

  1. ICMP ping — ping -c 1 -W <timeout> <ip> via subprocess. Fast, but phones in deep sleep often block pings.
  2. ARP fallback — 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.
⁠Debounced state machine

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

⁠Important: static IPs required

Detection is IP-only, not by hostname. You must configure static DHCP reservations on your router for each tracked phone.

⁠Startup behavior

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


⁠3 — Prerequisites

  • Router: static DHCP reservations for each tracked phone.
  • Raspberry Pi (recommended deployment): Raspberry Pi 4 running Raspbian with Docker installed.
  • Development: Docker Desktop (Windows or Linux).
  • Notifications (optional): ntfy.sh account or self-hosted ntfy instance.

⁠4 — Configuration

⁠4.1 Phone list — config/config.yaml
cp 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
FieldRequiredDescription
phoneNameyesFree-form name shown in notifications. Internal ID is its slug (anne-s25-edge).
phoneIpyesLAN IP, must be statically reserved on your router.
notifyWhenLeavingno (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.

⁠4.2 Secrets and settings — .env
cp .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.


⁠5 — Start, stop, restart

⁠Dev setup (one-time)

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.

⁠Start
docker compose up -d
⁠Follow logs
docker compose logs -f
⁠Stop
docker compose down
⁠Full rebuild (after code or Dockerfile changes)
# Linux / macOS
./scripts/rebuild.sh

# Windows PowerShell 7
./scripts/rebuild.ps1

These scripts chain down → build → up -d → logs -f automatically.

⁠Production deployment (Raspberry Pi)

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
⁠Check container health
docker ps
# or
docker inspect --format='{{.State.Health.Status}}' whoishere
⁠Local development without Docker
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000

⁠6 — API — Endpoints and JSON responses

All protected endpoints require the header:

Authorization: Bearer <API_TOKEN>
⁠GET /healthz — public

Liveness probe. Used by the Docker healthcheck. No authentication required.

{ "status": "ok" }

⁠GET / — protected

Full 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"
    }
  ]
}
FieldTypePossible values
house_occupiedbool—
house_labelstring"Occupée" · "Vide" · "Inconnue"
occupants_countintNumber of phones in present state
updated_atISO 8601 UTC datetime—
phones[].idstringSlug of phoneName (e.g. anne-s25-edge)
phones[].labelstring"Présent" · "Absent" · "Inconnu"
phones[].last_seendatetime or nullLast positive detection
phones[].last_checkeddatetime or nullLast scan attempt

⁠GET /phones/{phone_id} — protected

Status 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 — protected

Sends 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.


⁠HTTP error codes
CodeReason
401Missing or invalid token
404Unknown phone_id
503API_TOKEN not set in .env

⁠7 — curl examples

Examples use shell variables $TOKEN for the API token and $HOST for the Raspberry Pi address.

export HOST=192.168.1.10   # Raspberry Pi address
export TOKEN=my-api-token
⁠Check service is alive (public)
curl -s http://$HOST:8000/healthz | jq .
⁠Full house status
curl -s http://$HOST:8000/ \
  -H "Authorization: Bearer $TOKEN" | jq .
⁠Is the house occupied? (single value)
curl -s http://$HOST:8000/ \
  -H "Authorization: Bearer $TOKEN" | jq .house_label
⁠List only phones currently present
curl -s http://$HOST:8000/ \
  -H "Authorization: Bearer $TOKEN" \
  | jq '[.phones[] | select(.present == true) | {name, last_seen}]'
⁠Status of a specific phone
curl -s http://$HOST:8000/phones/anne-s25-edge \
  -H "Authorization: Bearer $TOKEN" | jq .
⁠Send a test notification
curl -s -X POST http://$HOST:8000/test-notify \
  -H "Authorization: Bearer $TOKEN" | jq .
⁠Minimal monitoring script
#!/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)"'

⁠8 — ntfy notifications

⁠Subscribe on mobile
  1. Install the ntfy app⁠ (Android / iOS).
  2. Add a subscription with URL https://ntfy.sh/<your-topic>.
  3. If the topic requires auth, configure username and password in the app.

Choose a hard-to-guess topic name (or use a private ntfy instance): anyone who knows the topic can read the notifications.

⁠Notification types
EventCondition
"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)

⁠9 — Homepage widget

Homepage⁠ supports custom API widgets via customapi.

⁠Store the token securely

In Homepage's .env (or docker-compose.yml):

HOMEPAGE_VAR_WHOISHERE_TOKEN=my-api-token
⁠Widget in 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.


⁠10 — Tech stack

ComponentTechnologyRole
HTTP APIFastAPI⁠ 0.136REST endpoint exposure
ASGI serverUvicorn⁠ 0.46Async server
Config validationPydantic⁠ 2.13 + pydantic-settings⁠ 2.14.env loading and validation
HTTP clienthttpx⁠ 0.28ntfy notification delivery
Phone configPyYAML⁠ 6.0config/config.yaml parsing
Network detectionsystem ping + ip neigh (iproute2)ICMP probe + ARP fallback
ContainerizationDocker + Docker ComposeDeployment
Base imagepython:3.12-slim—
Notificationsntfy.sh⁠Mobile push
Target deploymentRaspberry Pi 4 (Raspbian)—

⁠11 — Known limitations

  • Docker Desktop Windows / macOS: 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.
  • Deep-sleeping phones: some phone models clear their ARP entry after an extended sleep, triggering a present → absent transition even though the phone is home and connected to Wi-Fi. Increasing ABSENCE_CONFIRMATIONS mitigates this.
  • No persistence: restarting the service resets all phones to unknown for a few scan cycles.
  • IP-only detection: does not work without static DHCP reservations on your router.

Tag summary

Content type

Image

Digest

sha256:aa990a295…

Size

60.2 MB

Last updated

5 months ago

docker pull hthouzard/whoishere