We have HomeKit automation in our house. Kitchen lights turn on when motion is detected. Works great—until my wife mentions she wants bright overhead lights when she walks in, while I just want dim under-cabinet lights at midnight. Motion sensors can’t tell us apart.
No switches to remember. No app to open. Just our Apple Watches, a $15 ESP32 board, and two days with Claude.
This is Holly (named after the ship’s computer in Red Dwarf)—a personal presence sensor that tracks specific people via their Apple Watch BLE signals and exposes that presence to HomeKit.
Why Did We Do This?
The problem: Traditional motion sensors can’t distinguish between people. They know “someone is here” but not who. This means automations are one-size-fits-all—when my wife enters the kitchen, she wants all lights at 100%. When I grab water at 2am, I want dim accent lighting only.
Existing solutions didn’t fit:
- Phone-based presence (Life360, Home Assistant): Too slow (30+ second delays), battery drain, requires cloud
- iBeacon tracking: Requires iOS app running in background, unreliable
- PIR + manual switches: Defeats the automation purpose
- Commercial products: Non-existent for per-person occupancy
The idea: Apple Watches broadcast BLE constantly for features like Auto Unlock. If we can identify specific watches, we can track specific people—and do it locally, fast, and reliably.
What Problems Needed Solving?
Apple’s Privacy Challenge
Apple devices rotate their BLE MAC address every 15 minutes to prevent tracking. You can’t just scan for a fixed address. The solution: BLE pairing exchanges an IRK (Identity Resolving Key) that lets you identify a device even as its MAC changes.
This meant building a full BLE GATT server for pairing, not just a scanner.
Hardware Selection
Requirements:
- BLE for scanning Apple Watches
- Thread for HomeKit mesh networking (avoids WiFi congestion)
- Matter SDK support (official HomeKit certification path)
- Under $20
Winner: Seeed XIAO ESP32-C6 ($14.99). I already knew these boards from a previous project—they’re my first choice when you need WiFi + BLE + Thread. The cheapest option with all three radios. ESP32 and ESP32-S3 lack the Thread radio. ESP32-H2 has Thread but no WiFi (needed for web UI).
Matter vs. HomeKit ADK
HomeKit Accessory Development Kit is deprecated. Matter is the official path forward. But Matter over Thread required:
- HomePod Mini or Apple TV 4K as Thread border router
- Understanding Matter’s device commissioning flow
- Coordinating BLE (for scanning) and CHIPoBLE (for Matter commissioning) on the same radio
Presence Detection Algorithm
RSSI (signal strength) is noisy. A static threshold causes flickering presence. The solution needed:
- Exponential moving average smoothing
- Hysteresis (different thresholds for arrival vs. departure)
- Grace periods for temporary signal loss
- Per-device calibration (Watch signals differ from Phone signals)
The Plan
Here’s the actual plan I gave Claude. It’s structured like a Claude Code implementation plan—copy this format for your own 1-2 day builds.
Plan: radiant-humming-presence.md
Implementation Plan: Holly Personal Presence Sensor
Overview
Build a Matter/HomeKit-compatible occupancy sensor that tracks specific people via their Apple Watch BLE signals, enabling per-person home automations.
Goals
- Detect presence of specific people within 2-3 seconds of room entry
- Expose to HomeKit as native occupancy sensor via Matter over Thread
- Survive MAC rotation - identify devices despite Apple’s 15-minute address changes
- Wife-approval reliability - no false absences, no missed detections
- Zero cloud dependency - all processing local on ESP32
Architecture Decisions
BLE Stack: NimBLE
- NimBLE-Arduino over ESP32’s Bluedroid: Lower memory footprint, simpler API
- Runs BLE observer (scanner) and GATT server (for pairing) simultaneously
- Supports IRK resolution for MAC address rotation handling
Matter Integration: Arduino SDK via pioarduino
- pioarduino fork required - official Espressif platform lacks Arduino Matter support for C6
- Matter runs on CHIPoBLE, conflicts with NimBLE scanning
- BLE coordination state machine switches between Matter commissioning and presence scanning
Presence Algorithm: RSSI + EMA Smoothing
- Exponential moving average (α=0.3) smooths noisy RSSI readings
- Hysteresis thresholds: -70 dBm present, -85 dBm absent
- Grace period: 15 seconds of signal loss before marking absent
- Debounce timer: 3 seconds of stable presence before marking present
State Management: Per-Device Tracking
- Each paired device has independent state: RSSI history, presence status, grace counters
- Composite occupancy: “room occupied” if ANY tracked person is present
- HomeKit sync: Force attribute update on boot (Matter bug workaround)
Implementation Tasks
Task 1: Platform Setup
- Install PlatformIO with pioarduino ESP32 platform
- Configure partitions for Matter NVS storage (4MB minimum)
- Create basic Matter occupancy sensor device
Files:
platformio.ini: Platform config, dependenciespartitions.csv: Custom partition tablesrc/main.cpp: Entry point with Matter setup
Task 2: BLE Pairing & IRK Capture
- Implement NimBLE GATT server with pairing-enabled service
- Capture IRK from bond event, store in NVS
- Create web UI pairing flow (start pairing → pair in iOS Settings → confirm)
Files:
src/ble_pairing.cpp: GATT server, bond callbackssrc/web_server.cpp: REST API for pairing controldata/index.html: Pairing wizard UI
Task 3: BLE Scanning & Presence Detection
- Run NimBLE observer to scan for paired devices
- Implement RSSI smoothing and hysteresis algorithm
- Add per-device calibration (configurable thresholds)
Files:
src/presence_detector.cpp: Core detection algorithmsrc/ble_scanner.cpp: NimBLE observer, IRK resolution
Task 4: Matter/HomeKit Integration
- Add Matter occupancy sensor cluster
- Implement BLE coordination state machine
- Sync presence state to HomeKit every 5 seconds
Files:
src/matter_device.cpp: Occupancy sensor implementationsrc/ble_coordinator.cpp: State machine to avoid BLE conflicts
Task 5: Web UI & Calibration
- Dashboard showing per-person presence state, RSSI graphs
- Calibration wizard: stand at desired distance, capture RSSI samples
- Device management: unpair devices, rename, adjust thresholds
Files:
data/dashboard.html: Real-time status displaydata/calibration.html: Calibration flowsrc/web_api.cpp: REST endpoints for device management
Task 6: Reliability Fixes (Code Review)
- Fix RSSI initialization bug (was comparing uninitialized values)
- Fix debounce timer reset (was never resetting, caused delayed detections)
- Fix Matter sync on boot (attributes not updating until first change)
- Add defensive null checks for NVS operations
Files:
src/presence_detector.cpp: RSSI init, debounce fixsrc/matter_device.cpp: Force sync on startupsrc/nvs_storage.cpp: Null safety
Critical Files
presence/
├── platformio.ini # Platform config, Matter SDK
├── partitions.csv # NVS partition table
├── src/
│ ├── main.cpp # Setup, loop coordination
│ ├── ble_pairing.cpp # GATT server, IRK capture
│ ├── ble_scanner.cpp # Observer, device scanning
│ ├── ble_coordinator.cpp # State machine for BLE conflicts
│ ├── presence_detector.cpp # RSSI smoothing, presence logic
│ ├── matter_device.cpp # Occupancy sensor cluster
│ ├── web_server.cpp # HTTP server, REST API
│ └── nvs_storage.cpp # Persistent storage
├── data/
│ ├── index.html # Pairing wizard
│ ├── dashboard.html # Live status dashboard
│ └── calibration.html # Threshold calibration
Verification
Phase 1: BLE Pairing
pio run --target upload --target monitor
# Expected output:
# [BLE] GATT server started
# [Web] Server at http://192.168.1.100
# Visit web UI, click "Start Pairing"
# Pair in iOS Settings > Bluetooth
# Check serial: [BLE] IRK captured for device: Apple Watch
Phase 2: Presence Detection
# Walk into room with Apple Watch
# Expected: [Presence] Person 1: present (RSSI: -65 dBm)
# Walk out of room
# Expected (after 15s grace): [Presence] Person 1: absent
# Check HomeKit app: Occupancy sensor shows "Occupied" / "Clear"
Phase 3: Reliability Test
- Leave room for 10 minutes → sensor shows “Clear”
- Re-enter room → sensor shows “Occupied” within 3 seconds
- Stand still for 5 minutes → sensor remains “Occupied” (no false absence)
- Check logs for missed scans or RSSI glitches
Trade-offs
Thread vs. WiFi
- Chose Thread for mesh networking, lower power, less congestion
- Trade-off: Requires HomePod Mini or Apple TV 4K as border router
- Alternative: Matter over WiFi works but floods network with BLE scan beacons
Per-Device vs. Room-Level Detection
- Chose per-device tracking with composite occupancy
- Trade-off: More complex (per-device state, calibration)
- Benefit: Future expansion - “Person 1 present” as separate sensor for advanced automations
Web UI vs. HomeKit-Only
- Chose web UI for calibration and debugging
- Trade-off: Requires WiFi even when using Thread for Matter
- Benefit: Can adjust thresholds without re-flashing firmware
Grace Period Duration
- Chose 15 seconds based on empirical testing
- Too short: False absences when Watch signal temporarily lost
- Too long: Delayed “absent” detection when leaving room
How Claude Code Actually Worked
The implementation plan above looks clean. Reality was messier.
Claude kept suggesting Homebridge as a “simpler” path—run MQTT on the ESP32, bridge to HomeKit via a Raspberry Pi. Technically correct. But I wanted to understand Matter natively, not hide complexity behind another layer.
Every time it suggested the easy route, I pushed back: “No, keep it on the ESP32. Make Matter work directly.” This forced both of us to learn the actual constraints: partition tables for NVS storage, BLE coordination between CHIPoBLE and NimBLE, Thread border router requirements.
That’s the value of AI-assisted development when done right—not accepting the first solution, but using the constraint-forcing to understand what’s truly possible. The assistant wanted the working solution. I wanted to understand why it worked.
Fun Challenges Encountered
The RSSI Initialization Bug
First version compared uninitialized RSSI values against thresholds. Result: Every device was immediately “present” even if not in range. The fix was subtle—initialize smoothed RSSI to -127 dBm (BLE minimum) instead of 0.
// Before (broken):
int8_t smoothedRssi; // Uninitialized, could be anything
// After (fixed):
int8_t smoothedRssi = -127; // BLE minimum signal strength
The BLE Coordination Dance
Matter commissioning uses CHIPoBLE. Presence scanning uses NimBLE. Same Bluetooth radio. Running both simultaneously caused:
- Symptom: Commissioning would hang at 33%
- Root cause: NimBLE observer stealing radio time from CHIPoBLE
- Solution: State machine that stops scanning during commissioning, resumes after
This taught me: BLE is half-duplex at the radio level. You can’t just “share” it between stacks.
The Matter Sync Mystery
After rebooting the ESP32, HomeKit would show stale occupancy state until someone’s presence changed. Looking at Matter attribute updates revealed the issue: attributes only sync on change events. If presence was “occupied” before reboot and “occupied” after, Matter never sent an update.
The fix: Force attribute sync on boot by calling emberAfOccupancySensingClusterServerAttributeChangedCallback() explicitly. This tells Matter “even though the value didn’t change, send it anyway.”
The Debounce Timer That Never Reset
Presence detection had a 3-second debounce to avoid flicker. But the timer never reset after transitioning to “present,” causing weird behavior:
- Walk in → 3-second delay (correct)
- Walk out and back in within 15s grace period → instant detection (wrong!)
The bug: debounce timer only reset on state change to “absent,” not on stable “present.” The fix was simple but took debugging to notice:
// Before:
if (isPresent && stateTransitioned) debounceTimer = 0;
// After:
if (isPresent) debounceTimer = 0; // Always reset when present
Per-Device RSSI Calibration
Initial testing used a single threshold (-70 dBm). Worked great for my Apple Watch. Failed completely for my wife’s iPhone—its signal strength was consistently 10 dBm weaker at the same distance.
Lesson: BLE signal strength varies by device model, antenna design, and even Watch orientation. The solution was per-device calibration with a wizard that samples RSSI at your desired “present” distance.
Results
Wife-approval-factor: 11/10. The moment I paired her watch and she walked into the kitchen—lights went to 100% overhead instead of my dim accent preference—she got it immediately. “Wait, it knows it’s me?” That’s when I knew it worked.
Performance:
- Detection latency: 2-3 seconds
- False absences: 0 in 2 weeks of testing
- Battery impact: Negligible (Watch already broadcasts BLE constantly)
What got built:
Once the core presence detection worked reliably, the fun experiments started:
- Per-person HomeKit sensors - Each person now shows as a separate occupancy sensor, enabling “if Hans is in living room AND it’s after 10pm…” automations
- OLED display with googly eyes - A tiny screen that shows who’s present with animated eyes that follow you. Pure fun factor, zero utility, 100% worth it.
The googly eyes were the best addition. My wife laughed when they tracked her across the kitchen. That’s when a “smart home project” became a “fun home project.”
Tech stack: ESP32-C6, NimBLE, Matter SDK, HomeKit, Thread Build time: 2 days of focused work, plus several debugging sessions over the next week. Day 1 (6 hours) got something barely working. Day 2 (8 hours) was deep-diving HomeKit and Thread concepts I didn’t fully understand. The real learning happened forcing Claude to keep everything on the ESP32—it kept trying to weasel out to Homebridge/MQTT solutions. By refusing the easy path, I learned what’s actually possible with local-only Matter devices. Hardware cost: $15 (ESP32-C6) + HomePod Mini you probably already have