dalehumby / ESPHome-Apple-Watch-detection

ESPHome BLE Apple Watch presence detection
MIT License
328 stars 16 forks source link
applewatch ble bluetooth bluetooth-low-energy esp32 esphome homeassistant

ESPHome BLE Apple Watch presence detection

An ESPHome config for ESP32 to reliably detect an Apple Watch for room level presence detection for use with Home Assistant.

Full working example: lounge.yaml

Presence detection

How BLE tracking works

To track a person or object, you attach a BLE tag like TrackR. Every few seconds the tracker broadcasts its presence to all listening receivers. It is identified by its unique MAC address. A BLE receiver, like the Raspberry Pi running room assistant, or ESP32 with ESPHome BLE RSSI sensor detects the broadcast and records the received signal strength (RSSI). If the signal is weak then the tag is far away, and if the signal is strong then the tag is near. You can use this to infer whether the tag is in the room or not, and base home automation on the tags presence or absence.

I didn't want to wear a BLE tag for home automation. But I do always wear my Apple Watch, and I know that has Bluetooth.

Apple Watch BLE tracking

Apple devices use BLE broadcasts to notify other Apple devices of their proximity, for use in Handoff, Find My and Airdrop. This is the Apple Continuity Protocol and has been somewhat reverse engineered.

However, for privacy reasons – to prevent tracking, the exact thing I want to do – Apple randomly generates a new MAC address about every 45 minutes. Without a stable MAC address, how can you tell which broadcast is your watch?

One of the Apple Watch broadcasts is the Nearby Info message. It is transmitted every few seconds, and it has enough information to reliably find an Apple Watch amongst all other BLE broadcast messages.

Apple Watch Nearby Info message

An example of the Nearby Info message (in hex) split for easier reading: 4c00 10 05 01 98 86b356, where

I can reliably detect an Apple Watch by searching all broadcast messages for Apple manufacturer ID 4c00 with data field that starts with 10 05 0X 98, where X is 0 through F.

How do you know if this is your Apple Watch?

Although I have not yet done this, you can confirm that it is your Apple watch by connecting to the device, listing all services, and then going through each service looking for the characteristic ID Model Number String that has a value such as Watch3,3.

As long as you're the only person with a series 3 Apple Watch with hardware revision 3 in your house, you will reliably be able to track yourself.

You can then store the MAC address until it cycles in ~45 minutes. When the MAC does change you rescan for the Nearby Info message 4c00 10 05 01 98 and service characteristic Model Number String Watch3,3.

Tracking Apple Watch with ESPHome

In each room where I want BLE tracking I have an ESP32 D1 mini running ESPHome.

ESP32

Each ESP32 continually scans for the Nearby Info BLE message, and when it finds it, gets the signal strength (RSSI). The strength fluctuates depending where you are in the room and how much of your body is shielding the watch from the receiver. A number of filters reduce the noise, and then make the decision whether you are in the room or not. The RSSI and presence is sent to Home Assistant via the native API, and also published to MQTT for use by e.g. Node-RED.

Config file explanation

I have templatised the lounge.yaml as much as possible.

substitutions:
  roomname: bedroom
  static_ip: 10.0.0.16
  yourname: Dale
  rssi_present: id(harssi_present).state
  rssi_not_present: id(harssi_not_present).state

roomname, static_ip and yourname should be self explanatory.

rssi_present and rssi_not_present are the upper and lower limits of signal strength that determine if you are present in a room or not. Anything stronger than the value in rssi_present and you are considered present. Anything below the value of rssi_not_present means that you are definitely not present in the room. (RSSI values are always negative. The closer to 0 a number is, the stronger the signal is: -50 is much stronger than -100.)

These values will need to be set for your particular room, so that your Watch is detected reliably within the room, and not detected when not in the room. To make tweaking these values easier you can add two input_number entities into Home Assistant's configuration.yaml file:

input_number:
  rssi_present:
    name: RSSI present threshold
    initial: -77
    min: -100
    max: -40
    step: 1
    unit_of_measurement: dBm
  rssi_not_present:
    name: RSSI not-present threshold
    initial: -90
    min: -100
    max: -40
    step: 1
    unit_of_measurement: dBm

These values are fetched by the ESPHome device from Home Assistant at runtime, using this yaml in ESPHome:

  - platform: homeassistant
    name: HA RSSI Present Value
    entity_id: input_number.rssi_present
    id: harssi_present
    internal: true
  - platform: homeassistant
    name: HA RSSI Not Present Value
    entity_id: input_number.rssi_not_present
    id: harssi_not_present
    internal: true

Within ESPHome, the detection works as follows:

esp32_ble_tracker:
  scan_parameters:
    interval: 1.2s
    window: 500ms
    active: false
  on_ble_advertise:
    - then:
      # Look for manufacturer data of form: 4c00 10 05 YY 98 XXXXXX
      # Where YY can be 01..0F or 20..2F; and XXXXXX is ignored
      - lambda: |-
          optional<int16_t> best_rssi = nullopt;
          for (auto data : x.get_manufacturer_datas()) {
            // Guard against non-Apple datagrams, or those that are too small.
            if (data.data.size() < 4 || data.uuid.to_string() != "0x004C" || data.data[0] != 0x10 || data.data[1] < 5) {
              continue;
            }
            const int16_t rssi = x.get_rssi();
            const uint8_t status_flags = data.data[2] >> 4;  // High nibble
            const uint8_t data_flags = data.data[3];
            if (data_flags == 0x98) {  // Match unlocked Apple Watches
              if (status_flags == 0 || status_flags == 2) {
                best_rssi = max(rssi, best_rssi.value_or(rssi));
                ESP_LOGD("ble_adv", "Found Apple Watch (mac %s) rssi %i", x.address_str().c_str(), rssi);
              } else {
                ESP_LOGD("ble_adv", "Possible Apple Watch? (mac %s) rssi %i, unrecognised status/action flags %#04x", x.address_str().c_str(), rssi, data.data[2]);
              }
            }
          }
          if (best_rssi) {
            id(apple_watch_rssi).publish_state(*best_rssi);
          }

This uses ESPHome's esp32_ble_tracker sensor. I've increased the interval and window to give as much time to detect the watch broadcast, but also enough time for the ESP32 to switch to WiFi to send results to MQTT and HA. We only listen for broadcasts, so active: false.

On all broadcasts, a lambda is run which looks for the manufacturer UUID 004c (note: big-endian), and manufacturer data that starts with 10 05 0X 98. Byte 2 appears to always be in the range 01 through 0F for Series 2 and 5, and 21 through 2F for Series 6 and 7. I am specifically looking for when the watch is unlocked (i.e. on my wrist) so that tracking stops when I take my watch off and it auto-locks. In unlocked state, byte 3 is 98. Locked state changes byte 3 to 18. (All values hex.)

For the case where multiple Apple Watches are detected, the strongest RSSI is published. As long as one watch is in the room, then the room is occupied.

The lambda publishes the RSSI to the apple_watch_rssi template sensor:

  - platform: template
    id: apple_watch_rssi
    name: "$yourname Apple Watch $roomname RSSI"
    device_class: signal_strength
    unit_of_measurement: dBm
    accuracy_decimals: 0
    filters:
      - exponential_moving_average:
          alpha: 0.3
          send_every: 1
    on_value:
      then:
        - lambda: |-
            if (id(apple_watch_rssi).state > $rssi_present) {
              id(room_presence_debounce).publish_state(1);
            } else if (id(apple_watch_rssi).state < $rssi_not_present) {
              id(room_presence_debounce).publish_state(0);
            }
        - script.execute: presence_timeout  # Publish 0 if no rssi received

This applies an exponential_moving_average filter. The alpha is fairly high to make it responsive when I actually change rooms, but just 'forgetful' enough to smooth out a lot of the random RSSI fluctuations.

Next apply hysteresis, so that only if the signal is stronger than rssi_present are you present (publish 1 to room_presence_debounce sensor), and only not present (publish 0) when signal strength of the watch is below rssi_not_present. If the RSSI is between these two values then it does not publish anything, and the system will stay in its current state: You either stay present or not present.

We also start a presence_timeout script, so that if no signal is detected (maybe you leave the house), then publish a 0 value to indicate not present.

script:
  # Publish event every 30 seconds when no rssi received
  id: presence_timeout
  mode: restart
  then:
    - delay: 30s
    - lambda: |-
        id(room_presence_debounce).publish_state(0);
    - script.execute: presence_timeout

The script is called each time an RSSI value is published. A 30 second delay timer is started. If the script is called again before the 30s delay is over, because of mode: restart the script is restarted, and the delay time is reset to 30s and 0 is never published. If no RSSI value has been received for 30s then publish a state of not present (0), and call the script again. Not present will continue to be published every 30s for as long as the Apple Watch is not detected.

Next is the debounce sensor:

  - platform: template
    id: room_presence_debounce
    filters:
      - sliding_window_moving_average:
          window_size: 3
          send_every: 1

in conjunction with the room presence binary sensor:

binary_sensor:
  - platform: template
    id: room_presence
    name: "$yourname $roomname presence"
    device_class: occupancy
    lambda: |-
      if (id(room_presence_debounce).state > 0.99) {
        return true;
      } else if (id(room_presence_debounce).state < 0.01) {
        return false;
      } else {
        return id(room_presence).state;
      }

The debounce sensor requires 3 consecutive readings to be the same to change presence. I use 0.99 and 0.01 in case there are floating point rounding issues with the sliding_window_moving_average filter.

The RSSI signal strength and room presence status are published to the Home Assistant native API as well as MQTT each time they change.

Integration to Home Assistant

Add the ESP32 tracker to Home Assistant using the ESPHome integration. You should see a new device with two entities: one for RSSI and the other for room presence.

Automations

Using room presence to automate turning on lights when you enter a room after dark. In your Home Assistant's automations.yaml add something like:

- id: enter_lounge_when_dark
  alias: "Turn on lounge lights on entry after sunset"
  trigger:
    - platform: state
      entity_id: binary_sensor.dale_lounge_presence
      to: "on"
  condition:
    - condition: numeric_state
      entity_id: sun.sun
      attribute: elevation
      below: 0
  action:
    - service: light.turn_on
      entity_id: light.lounge_light

Device Tracker

Dale Home

I wanted Home Assistant to use my Apple Watch to determine whether I was home or not_home. This is done by using the presence binary sensors as a device tracker. You have to do this with an automation.

In automations.yaml I added the following:

- id: dale_apple_watch_device_tracker
  alias: Dale Apple Watch Tracker
  trigger:
    - platform: state
      entity_id: binary_sensor.dale_bedroom_presence
    - platform: state
      entity_id: binary_sensor.dale_lounge_presence
  action:
    - service: device_tracker.see
      data:
        dev_id: dale_apple_watch
        location_name: >
          {% if is_state("binary_sensor.dale_bedroom_presence", "on") 
             or is_state("binary_sensor.dale_lounge_presence", "on") %}
            home
          {% elif is_state("binary_sensor.dale_bedroom_presence", "off") 
             and is_state("binary_sensor.dale_lounge_presence", "off") %}
            not_home
          {% else %}
            unknown
          {% endif %}
        source_type: "bluetooth_le"

The automation is called when any of the BLE trackers changes state. When any tracker detects my watch (note or) then I am home; when none of the trackers can detect my watch (note and) then I am not_home; and at start-up when the sensors haven't been initialised then the state is unknown. The device_tracker.see service adds my apple watch to known_devices.yaml, and makes the device tracker available as an entity.

From there, you can add the device tracker to yourself in People, and HA will use that (as well as your other device trackers) to determine if you are home or not.

Device Tracker

Reliability

The BLE tracker itself works very well. However, RSSI fluctuates wildly depending on where in the room you are, how your arm/watch is positioned, and the position of the ESP32 receiver. There is a trade-off between reliability and speed: Filters could reduce the false detections, but would be slow to change (and therefore automations would be slow to act), or automations can be fast, but trigger when they shouldn't.

My goal is to have an automation trigger within 3-5 seconds of entering a room, and never when I am not in the room.

As you can see from the image below, even though I am in the lounge almost the entire day (my home office desk is there), presence detection is noisy.

Lounge presence history

You will have to adjust the RSSI detection limits and filters and the position of your ESP32 in the room for best reliability. In short, don't expect miracles.

I find that a hybrid approach is best:

Tested and works with

Prior work on fingerprinting Apple device

Similar presence detection projects

Contributing

This has been tested on Series 3, 5, 6, 7 Apple Watches. If this doesn't work on your Apple watch please open an Issue.

I'd prefer that this was a custom ESPHome sensor, similar to Xiaomi Mijia BLE Sensors, instead of 100 lines of yaml. If you can help with the C, please let me know.

All feedback welcome.