Aqara vibration sensor: use device temperature for dryer notifications

TL;DR: The Aqara vibration entity was too sparse and noisy to track my dryer,
but the same sensor’s diagnostic device temperature is a much more reliable
activity proxy when interpreted as relative rise and peak-relative fall. It
usually turns on a few minutes after the dryer starts and stays on roughly 30-45
minutes after the drum stops, so this is conservative availability detection,
not exact cycle timing. That tradeoff works well for my main use: gating a
separate “washer is done and dryer is available” reminder. The template below
avoids a fixed temperature threshold, so it tolerates changes in room temperature.

I had an Aqara vibration sensor mounted directly on my dryer. The obvious plan was
to use its vibration entity to decide when the dryer was running.

That signal was not useful enough in my installation. A continuous tumble only
produced a few discrete vibration events per hour, with quiet stretches between
them. The nearby washer could also shake the dryer cabinet and produce cross-talk.
Counting vibration events led to missed cycles, awkward time windows, and noisy
“probably done” heuristics.

The useful signal turned out to be an entity I had mostly ignored:
sensor.aqara_dryer_device_temperature.

This is the sensor’s internal device temperature, not a calibrated measurement of
the dryer’s air or clothes. Because the sensor is attached to the metal cabinet,
however, heat from a running dryer conducts into it. In my setup it rises by about
10 degrees F during a cycle. The reading is coarse, in roughly 1 degree C buckets,
and only reports when it changes, but it is much more stable than the vibration
events.

The important part was to avoid absolute temperature thresholds. Room temperature
changes, and the cabinet can remain warm for hours after a load finishes. Instead,
I use a small state machine:

  1. Start when a new reading rises by at least 2 degrees C, either in one jump or
    across two or more consecutive rises within five minutes.
  2. While running, remember the highest temperature seen.
  3. Finish when the reading falls at least 2 degrees C below that peak.

That makes both edges relative to the sensor’s own recent behavior. It does not
matter whether the room started warm or cool.

There is a real latency tradeoff. In my installation, the rise appears a few
minutes after startup, while cabinet cooling means binary_sensor.dryer_running
commonly remains on for roughly 30-45 minutes after the drum actually stops. That
is too slow for precise cycle timing, but it is the most reliable proxy I have
found for whether the dryer should still be treated as unavailable.

My most useful automation consumes this as a gate: a separate “washer is done and
dryer is available” nag can fire only when the washer is finished and this binary
sensor is off. That washer-done logic is much simpler, so I am keeping this post
focused on deriving the dryer state.

Trigger-based template binary sensor

Replace every occurrence of sensor.aqara_dryer_device_temperature with your
device-temperature entity. This template automatically uses a 2.0 threshold for
Celsius states or 3.6 for Fahrenheit states.

If your configuration already has a template: key, merge the list item under it
rather than adding a second key.

template:
  - trigger:
      - platform: state
        entity_id: sensor.aqara_dryer_device_temperature
      - platform: time_pattern
        minutes: "/1"
    binary_sensor:
      - name: Dryer running
        unique_id: dryer_running_from_device_temperature
        device_class: running
        icon: mdi:tumble-dryer
        state: >
          {% set t = states('sensor.aqara_dryer_device_temperature') | float(none) %}
          {% set temp_state = states.sensor.aqara_dryer_device_temperature %}
          {% set unit = state_attr('sensor.aqara_dryer_device_temperature', 'unit_of_measurement') | string %}
          {% set threshold = 3.6 if 'F' in unit else 2.0 %}
          {% set sensor_ts = temp_state.last_changed if temp_state is not none else none %}
          {% set prior_on = this is not none and this.state == 'on' %}
          {% set prior_t = (this.attributes.last_temp | float(none)) if this is not none and this.attributes is defined and 'last_temp' in this.attributes else none %}
          {% set prior_sensor_ts = this.attributes.last_sensor_ts if this is not none and this.attributes is defined and 'last_sensor_ts' in this.attributes else none %}
          {% set sample_changed = sensor_ts is not none and (prior_sensor_ts is none or (sensor_ts | as_datetime) > (prior_sensor_ts | as_datetime)) %}
          {% set prior_window_start = (this.attributes.rise_window_start | float(none)) if this is not none and this.attributes is defined and 'rise_window_start' in this.attributes else none %}
          {% set prior_window_ts = this.attributes.rise_window_ts if this is not none and this.attributes is defined and 'rise_window_ts' in this.attributes else none %}
          {% set prior_window_steps = (this.attributes.rise_window_steps | int(0)) if this is not none and this.attributes is defined and 'rise_window_steps' in this.attributes else 0 %}
          {% set prior_peak = (this.attributes.peak_temp | float(0)) if this is not none and this.attributes is defined and 'peak_temp' in this.attributes else 0 %}
          {% if t is none %}
            {{ prior_on }}
          {% elif not prior_on %}
            {% if prior_t is none or not sample_changed %}
              false
            {% else %}
              {% set delta = t - prior_t %}
              {% set single_jump = (delta | round(1)) >= threshold %}
              {% if delta > 0 and prior_window_start is not none and prior_window_ts is not none and (((sensor_ts | as_datetime) - (prior_window_ts | as_datetime)).total_seconds() <= 300) %}
                {% set window_steps = prior_window_steps + 1 %}
                {% set window_rise = t - prior_window_start %}
              {% elif delta > 0 %}
                {% set window_steps = 1 %}
                {% set window_rise = delta %}
              {% else %}
                {% set window_steps = 0 %}
                {% set window_rise = 0 %}
              {% endif %}
              {% set multi_step_rise = window_steps >= 2 and (window_rise | round(1)) >= threshold %}
              {{ single_jump or multi_step_rise }}
            {% endif %}
          {% else %}
            {{ (t | round(1)) > ((prior_peak - threshold) | round(1)) }}
          {% endif %}
        attributes:
          chip_temperature: "{{ states('sensor.aqara_dryer_device_temperature') }}"
          last_temp: "{{ states('sensor.aqara_dryer_device_temperature') }}"
          last_sensor_ts: "{{ states.sensor.aqara_dryer_device_temperature.last_changed if states.sensor.aqara_dryer_device_temperature is not none else none }}"
          rise_window_start: >
            {% set t = states('sensor.aqara_dryer_device_temperature') | float(none) %}
            {% set temp_state = states.sensor.aqara_dryer_device_temperature %}
            {% set sensor_ts = temp_state.last_changed if temp_state is not none else none %}
            {% set prior_on = this is not none and this.state == 'on' %}
            {% set prior_t = (this.attributes.last_temp | float(none)) if this is not none and this.attributes is defined and 'last_temp' in this.attributes else none %}
            {% set prior_sensor_ts = this.attributes.last_sensor_ts if this is not none and this.attributes is defined and 'last_sensor_ts' in this.attributes else none %}
            {% set sample_changed = sensor_ts is not none and (prior_sensor_ts is none or (sensor_ts | as_datetime) > (prior_sensor_ts | as_datetime)) %}
            {% set prior_window_start = (this.attributes.rise_window_start | float(none)) if this is not none and this.attributes is defined and 'rise_window_start' in this.attributes else none %}
            {% set prior_window_ts = this.attributes.rise_window_ts if this is not none and this.attributes is defined and 'rise_window_ts' in this.attributes else none %}
            {% if prior_on or t is none or prior_t is none or not sample_changed %}
              {{ prior_window_start if prior_window_start is not none else none }}
            {% else %}
              {% set delta = t - prior_t %}
              {% if delta > 0 and prior_window_start is not none and prior_window_ts is not none and (((sensor_ts | as_datetime) - (prior_window_ts | as_datetime)).total_seconds() <= 300) %}
                {{ prior_window_start }}
              {% elif delta > 0 %}
                {{ prior_t }}
              {% else %}
                {{ none }}
              {% endif %}
            {% endif %}
          rise_window_ts: >
            {% set t = states('sensor.aqara_dryer_device_temperature') | float(none) %}
            {% set temp_state = states.sensor.aqara_dryer_device_temperature %}
            {% set sensor_ts = temp_state.last_changed if temp_state is not none else none %}
            {% set prior_on = this is not none and this.state == 'on' %}
            {% set prior_t = (this.attributes.last_temp | float(none)) if this is not none and this.attributes is defined and 'last_temp' in this.attributes else none %}
            {% set prior_sensor_ts = this.attributes.last_sensor_ts if this is not none and this.attributes is defined and 'last_sensor_ts' in this.attributes else none %}
            {% set sample_changed = sensor_ts is not none and (prior_sensor_ts is none or (sensor_ts | as_datetime) > (prior_sensor_ts | as_datetime)) %}
            {% set prior_window_ts = this.attributes.rise_window_ts if this is not none and this.attributes is defined and 'rise_window_ts' in this.attributes else none %}
            {% set prior_window_start = (this.attributes.rise_window_start | float(none)) if this is not none and this.attributes is defined and 'rise_window_start' in this.attributes else none %}
            {% if prior_on or t is none or prior_t is none or not sample_changed %}
              {{ prior_window_ts if prior_window_ts is not none else none }}
            {% else %}
              {% set delta = t - prior_t %}
              {% if delta > 0 and prior_window_start is not none and prior_window_ts is not none and (((sensor_ts | as_datetime) - (prior_window_ts | as_datetime)).total_seconds() <= 300) %}
                {{ prior_window_ts }}
              {% elif delta > 0 %}
                {{ prior_sensor_ts }}
              {% else %}
                {{ none }}
              {% endif %}
            {% endif %}
          rise_window_steps: >
            {% set t = states('sensor.aqara_dryer_device_temperature') | float(none) %}
            {% set temp_state = states.sensor.aqara_dryer_device_temperature %}
            {% set sensor_ts = temp_state.last_changed if temp_state is not none else none %}
            {% set prior_on = this is not none and this.state == 'on' %}
            {% set prior_t = (this.attributes.last_temp | float(none)) if this is not none and this.attributes is defined and 'last_temp' in this.attributes else none %}
            {% set prior_sensor_ts = this.attributes.last_sensor_ts if this is not none and this.attributes is defined and 'last_sensor_ts' in this.attributes else none %}
            {% set sample_changed = sensor_ts is not none and (prior_sensor_ts is none or (sensor_ts | as_datetime) > (prior_sensor_ts | as_datetime)) %}
            {% set prior_window_steps = (this.attributes.rise_window_steps | int(0)) if this is not none and this.attributes is defined and 'rise_window_steps' in this.attributes else 0 %}
            {% set prior_window_ts = this.attributes.rise_window_ts if this is not none and this.attributes is defined and 'rise_window_ts' in this.attributes else none %}
            {% set prior_window_start = (this.attributes.rise_window_start | float(none)) if this is not none and this.attributes is defined and 'rise_window_start' in this.attributes else none %}
            {% if prior_on or t is none or prior_t is none or not sample_changed %}
              {{ prior_window_steps }}
            {% else %}
              {% set delta = t - prior_t %}
              {% if delta > 0 and prior_window_start is not none and prior_window_ts is not none and (((sensor_ts | as_datetime) - (prior_window_ts | as_datetime)).total_seconds() <= 300) %}
                {{ prior_window_steps + 1 }}
              {% elif delta > 0 %}
                1
              {% else %}
                0
              {% endif %}
            {% endif %}
          peak_temp: >
            {% set t = states('sensor.aqara_dryer_device_temperature') | float(0) %}
            {% set prior_on = this is not none and this.state == 'on' %}
            {% set prior_peak = (this.attributes.peak_temp | float(0)) if this is not none and this.attributes is defined and 'peak_temp' in this.attributes else 0 %}
            {% if not prior_on %}
              0
            {% else %}
              {{ [prior_peak, t] | max }}
            {% endif %}

The one-minute trigger is intentional. It lets the template settle its internal
attributes, but sample_changed prevents the same sparse battery-sensor report
from being counted repeatedly as a new rise.

Dryer-finished notification

Once the template sensor is working, the notification is ordinary Home Assistant
automation YAML:

automation:
  - alias: Dryer cycle complete
    triggers:
      - trigger: state
        entity_id: binary_sensor.dryer_running
        from: "on"
        to: "off"
    actions:
      - action: notify.mobile_app_your_phone
        data:
          title: Dryer done
          message: Fold or hang the clothes
          data:
            tag: laundry_dryer
            notification_icon: mdi:tumble-dryer
    mode: single

On my hardware, this completion notification arrives 30-45 minutes late by design.
The conservative dryer_running availability gate is more useful to me than the
exact notification time.

Calibration

Do not copy my 2 degree C threshold blindly.

  1. Attach the sensor firmly to a part of the cabinet that warms during a cycle.
    Keep it away from the hottest surfaces and follow the sensor’s temperature and
    battery limits.
  2. Record at least one idle day and several complete cycles.
  3. Look at changes between reported buckets, not just the maximum temperature.
  4. Pick a rise large enough to reject normal room drift and a peak-relative drop
    that occurs close enough to the actual end for your use.
  5. Validate against the physical dryer before enabling notifications for everyone.

My sensor reports in 1 degree C buckets, so 2 degrees C means two buckets. A finer
or noisier sensor may need different numbers and timing.

What the recorder showed

After the final relative-rise version was installed, I replayed 51 days of Home
Assistant recorder data:

  • 110 complete detected warm intervals
  • every start followed a newly reported rise of at least 2 degrees C
  • every stop followed a drop of at least 2 degrees C from the remembered peak
  • no interval was shorter than 34 minutes, so there was no short on/off chatter
  • 97 of 110 had either laundry-room motion around the interval or a washer
    completion in the preceding six hours
  • only 71 of 110 had even one vibration event, illustrating why vibration was a
    poor required signal here

Those are not 110 manually labelled dryer loads, so this is field evidence rather
than a measured false-positive rate. Seven intervals lasted more than three hours;
back-to-back loads or slow cabinet cooling can merge into one long “running”
interval. This detects a heating stretch, not exact drum rotation.

Limits

  • Device temperature is diagnostic telemetry, not a calibrated appliance probe.
  • Reports from a battery Zigbee device can be delayed or missing.
  • Startup usually lags by a few minutes. Shutdown commonly lags by 30-45 minutes
    while the cabinet cools enough to cross another coarse temperature bucket.
  • Sunlight, a nearby heat source, or a hot washer may create a similar rise.
  • Back-to-back loads may remain one continuous warm interval.
  • This is suitable for convenience notifications, not fire detection, overheat
    protection, or any other safety function.

If your dryer exposes power consumption, a smart plug or circuit monitor is usually
the cleaner signal. This was useful because I already owned the Aqara sensor, its
vibration channel was disappointing, and its otherwise-uninteresting diagnostic
temperature turned out to be the better feature.