SONOFF SNZB-02DR2 (Zigbee2MQTT) - Remote Sources

SONOFF SNZB-02DR2: Show remote sensor readings on the display

Import this blueprint into Home Assistant

This blueprint sends temperature and humidity readings from up to two Home Assistant sensors to the SONOFF SNZB-02DR2 display using Zigbee2MQTT. The two independent remote slots appear as EXT1 and EXT2.

What’s included

  • Independent temperature and humidity sources for EXT1 and EXT2.
  • Validation for numeric values, supported ranges, and reading freshness.
  • Fahrenheit and Kelvin conversion to °C.
  • Per-reading fallback behavior.
  • Configurable refresh interval and delay between MQTT writes.
  • Optional restoration of the online state.

Requirements

  • Home Assistant 2024.10 or later.
  • MQTT integration enabled.
  • Zigbee2MQTT exposing the source_N_* properties.
  • SONOFF SNZB-02DR2 firmware 1.0.5 or later.

ZHA is not supported; this blueprint publishes directly to the Zigbee2MQTT MQTT topic.

Notes

The SNZB-02DR2 is battery-powered and may apply updated remote readings at its next check-in. Briefly pressing the button on its back can refresh the display sooner.

Blueprint and instructions: GitHub - ricfio/ha-blueprint-sonoff-snzb-02dr2-remote-sources: Home Assistant blueprint to show remote temperature/humidity (EXT1/EXT2) on the SONOFF SNZB-02DR2 display via Zigbee2MQTT. · GitHub

blueprint:
  name: SONOFF SNZB-02DR2 (Zigbee2MQTT) - Remote Sources
  description: |
    Sends the temperature and/or humidity of up to two other sensors to the
    **SONOFF SNZB-02DR2 (AirGuard TH)** so they are shown on its display.

    **How it works**
    The display has two independent remote slots: slot 1 is shown as **EXT1** and
    slot 2 as **EXT2**, and they can be used together. For each slot you can pick a
    temperature sensor, a humidity sensor, or both (each one is optional). Every value
    is written in its own MQTT message (`source_N_temperature` / `source_N_humidity`)
    with a short pause in between.

    **Requirements**
    - Zigbee2MQTT exposing `source_N_*` properties for the display
    - MQTT integration enabled in Home Assistant
    - Home Assistant 2024.10 or later
    - SONOFF SNZB-02DR2 firmware 1.0.5 or later (first version with the two remote sources)

    **Tested with**
    - SONOFF SNZB-02DR2 firmware 1.0.5 (other firmware versions have not been tested)

    **Notes**
    - The display is a battery-powered sleepy device. According to the Zigbee2MQTT
      documentation, new values are applied at its next check-in (the screen refreshes
      from the remote sources every 30 minutes) or immediately after briefly pressing
      the button on its back.
    - A reading is sent only if it is numeric, uses a supported unit, is within range
      (temperature -20 to 60 °C, humidity 0 to 100 %) and is not older than the configured
      maximum age. Fahrenheit and Kelvin temperatures are converted to °C.
    - Source changes are debounced and the latest values are evaluated after the quiet
      period; periodic refresh and Home Assistant startup send without that delay.
    - Invalid readings use the configured per-item fallback. When enabled, `online` is
      published immediately before a valid value.

    **Version:** 1.2.0
  domain: automation
  author: ricfio
  source_url: https://github.com/ricfio/ha-blueprint-sonoff-snzb-02dr2-remote-sources/blob/main/blueprints/automation/sonoff-snzb-02dr2-remote-sources.yaml
  homeassistant:
    min_version: 2024.10.0
  input:
    display_section:
      name: Display
      icon: mdi:monitor-dashboard
      collapsed: false
      input:
        display_name:
          name: Zigbee2MQTT name of the display
          description: >-
            The friendly_name of the SNZB-02DR2 in Zigbee2MQTT, exactly as it appears
            there (case-sensitive).
          selector:
            text:
        base_topic:
          name: MQTT base topic
          description: The Zigbee2MQTT base_topic. Keep the default unless you changed it.
          default: zigbee2mqtt
          selector:
            text:
    ext1_section:
      name: EXT1 (remote slot 1)
      icon: mdi:numeric-1-box-outline
      collapsed: false
      input:
        temp_entity_1:
          name: EXT1 temperature sensor
          description: Leave empty if you do not want to send a temperature to EXT1.
          default: ""
          selector:
            entity:
              filter:
                - domain: sensor
                  device_class: temperature
        temp_fallback_1:
          name: EXT1 temperature fallback
          description: What to write if the EXT1 temperature source is not valid.
          default: default
          selector:
            select:
              mode: list
              options:
                - label: Use the default fallback
                  value: default
                - label: Do nothing
                  value: none
                - label: Mark offline
                  value: offline
                - label: Unbind
                  value: unbound
        hum_entity_1:
          name: EXT1 humidity sensor
          description: Leave empty if you do not want to send a humidity to EXT1.
          default: ""
          selector:
            entity:
              filter:
                - domain: sensor
                  device_class: humidity
        hum_fallback_1:
          name: EXT1 humidity fallback
          description: What to write if the EXT1 humidity source is not valid.
          default: default
          selector:
            select:
              mode: list
              options:
                - label: Use the default fallback
                  value: default
                - label: Do nothing
                  value: none
                - label: Mark offline
                  value: offline
                - label: Unbind
                  value: unbound
    ext2_section:
      name: EXT2 (remote slot 2)
      icon: mdi:numeric-2-box-outline
      collapsed: false
      input:
        temp_entity_2:
          name: EXT2 temperature sensor
          description: Leave empty if you do not want to send a temperature to EXT2.
          default: ""
          selector:
            entity:
              filter:
                - domain: sensor
                  device_class: temperature
        temp_fallback_2:
          name: EXT2 temperature fallback
          description: What to write if the EXT2 temperature source is not valid.
          default: default
          selector:
            select:
              mode: list
              options:
                - label: Use the default fallback
                  value: default
                - label: Do nothing
                  value: none
                - label: Mark offline
                  value: offline
                - label: Unbind
                  value: unbound
        hum_entity_2:
          name: EXT2 humidity sensor
          description: Leave empty if you do not want to send a humidity to EXT2.
          default: ""
          selector:
            entity:
              filter:
                - domain: sensor
                  device_class: humidity
        hum_fallback_2:
          name: EXT2 humidity fallback
          description: What to write if the EXT2 humidity source is not valid.
          default: default
          selector:
            select:
              mode: list
              options:
                - label: Use the default fallback
                  value: default
                - label: Do nothing
                  value: none
                - label: Mark offline
                  value: offline
                - label: Unbind
                  value: unbound
    timing_section:
      name: Update frequency
      icon: mdi:timer-cog-outline
      collapsed: true
      input:
        refresh_pattern:
          name: Periodic refresh
          description: >-
            Resends the values at regular intervals, even without changes. Keeps the
            display aligned and recovers from skipped updates.
          default: "/30"
          selector:
            select:
              mode: dropdown
              options:
                - label: Every 5 minutes
                  value: "/5"
                - label: Every 10 minutes
                  value: "/10"
                - label: Every 15 minutes
                  value: "/15"
                - label: Every 20 minutes
                  value: "/20"
                - label: Every 30 minutes
                  value: "/30"
        throttle_seconds:
          name: Source update debounce
          description: >-
            Waits for this many seconds of quiet after a source state change, then sends
            the latest values. Further changes restart the wait, so the final useful
            update is not discarded. Periodic refresh and startup are sent immediately.
          default: 60
          selector:
            number:
              min: 0
              max: 240
              step: 10
              unit_of_measurement: s
              mode: slider
    safety_section:
      name: Safety and fallback
      icon: mdi:shield-check-outline
      collapsed: true
      input:
        stale_minutes:
          name: Maximum age of a source reading
          description: >-
            If a source sensor has not reported for longer than this, its value is
            considered unreliable. Set to 0 to disable the check.
          default: 120
          selector:
            number:
              min: 0
              max: 720
              step: 10
              unit_of_measurement: min
              mode: box
        fallback_mode:
          name: Default fallback when a source reading is not valid
          description: >-
            Applies only to the affected item (for example EXT1 temperature).
            "Mark offline" writes `offline` to its `source_N_*_state`; "Unbind" writes
            `unbound`. How the display renders these states depends on the firmware.
            Each item can override this in the EXT1 / EXT2 sections.
          default: none
          selector:
            select:
              mode: list
              options:
                - label: Do nothing
                  value: none
                - label: Mark offline
                  value: offline
                - label: Unbind
                  value: unbound
        restore_online:
          name: Restore "online" when a source is valid again
          description: >-
            Writes `online` to the configured item's `source_N_*_state` immediately
            before its valid value. State-only updates do not erase the measurement.
            Adds one message for each valid configured item.
          default: false
          selector:
            boolean:
    advanced_section:
      name: Advanced
      icon: mdi:cog-outline
      collapsed: true
      input:
        set_status_using:
          name: Enable remote sources (required)
          description: >-
            Sets remote_source_status to "using" before sending values,
            as required by firmware 1.0.5 and later to enable remote sources.
          default: true
          selector:
            boolean:
        write_delay:
          name: Pause between writes
          description: >-
            Seconds to wait between two consecutive messages sent to the display.
            Avoids a write overriding the previous one.
          default: 2
          selector:
            number:
              min: 0
              max: 10
              step: 1
              unit_of_measurement: s
              mode: slider

mode: restart
max_exceeded: silent

variables:
  display_name: !input display_name
  base_topic: !input base_topic
  temp_entity_1: !input temp_entity_1
  hum_entity_1: !input hum_entity_1
  temp_entity_2: !input temp_entity_2
  hum_entity_2: !input hum_entity_2
  stale_minutes: !input stale_minutes
  throttle_seconds: !input throttle_seconds
  fallback_mode: !input fallback_mode
  restore_online: !input restore_online
  temp_fallback_1: !input temp_fallback_1
  hum_fallback_1: !input hum_fallback_1
  temp_fallback_2: !input temp_fallback_2
  hum_fallback_2: !input hum_fallback_2
  set_status_using: !input set_status_using
  write_delay: !input write_delay
  set_topic: "{{ base_topic | trim('/') }}/{{ display_name | trim('/') }}/set"

triggers:
  - trigger: event
    event_type: state_changed
    event_data:
      entity_id: !input temp_entity_1
    id: source_changed
  - trigger: event
    event_type: state_changed
    event_data:
      entity_id: !input hum_entity_1
    id: source_changed
  - trigger: event
    event_type: state_changed
    event_data:
      entity_id: !input temp_entity_2
    id: source_changed
  - trigger: event
    event_type: state_changed
    event_data:
      entity_id: !input hum_entity_2
    id: source_changed
  - trigger: time_pattern
    minutes: !input refresh_pattern
    id: periodic
  - trigger: homeassistant
    event: start
    id: startup

conditions:
  - alias: React to state or unit changes, but ignore unrelated attribute changes
    condition: template
    value_template: >-
      {{ trigger.id != 'source_changed'
         or trigger.event.data.old_state is none
         or trigger.event.data.new_state is none
         or trigger.event.data.new_state.state != trigger.event.data.old_state.state
         or trigger.event.data.new_state.attributes.unit_of_measurement
            != trigger.event.data.old_state.attributes.unit_of_measurement }}
actions:
  - alias: Coalesce source changes and use the latest state
    if:
      - condition: template
        value_template: "{{ trigger.id == 'source_changed' and throttle_seconds | int(0) > 0 }}"
    then:
      - delay:
          seconds: "{{ throttle_seconds | int(0) }}"
  - alias: Optionally set remote_source_status to "using"
    if:
      - condition: template
        value_template: "{{ set_status_using }}"
    then:
      - action: mqtt.publish
        data:
          topic: "{{ set_topic }}"
          qos: 1
          retain: false
          payload: '{"remote_source_status": "using"}'
      - delay:
          seconds: "{{ write_delay }}"
  - alias: Process each remote slot
    repeat:
      for_each:
        - slot: 1
          temp: "{{ temp_entity_1 }}"
          hum: "{{ hum_entity_1 }}"
          t_fb: "{{ temp_fallback_1 }}"
          h_fb: "{{ hum_fallback_1 }}"
        - slot: 2
          temp: "{{ temp_entity_2 }}"
          hum: "{{ hum_entity_2 }}"
          t_fb: "{{ temp_fallback_2 }}"
          h_fb: "{{ hum_fallback_2 }}"
      sequence:
        - variables:
            slot: "{{ repeat.item.slot }}"
            t_ent: "{{ repeat.item.temp }}"
            h_ent: "{{ repeat.item.hum }}"
            # Effective fallback of each item: per-item override or the default
            t_fallback: "{{ fallback_mode if repeat.item.t_fb == 'default' else repeat.item.t_fb }}"
            h_fallback: "{{ fallback_mode if repeat.item.h_fb == 'default' else repeat.item.h_fb }}"
            # Keep the full precision for range validation; round only for transmission.
            t_c_raw: >-
              {%- if t_ent | string | length > 0 and is_number(states(t_ent)) -%}
                {%- set v = states(t_ent) | float -%}
                {%- set u = state_attr(t_ent, 'unit_of_measurement') | string | trim -%}
                {%- if u in ['°C', '℃'] -%}{{ v }}
                {%- elif u in ['°F', '℉'] -%}{{ (v - 32) * 5 / 9 }}
                {%- elif u == 'K' -%}{{ v - 273.15 }}
                {%- endif -%}
              {%- endif -%}
            t_c: "{{ t_c_raw | float | round(1) if t_c_raw != '' else '' }}"
            t_unit: "{{ state_attr(t_ent, 'unit_of_measurement') | string | trim }}"
            h_unit: "{{ state_attr(h_ent, 'unit_of_measurement') | string | trim }}"
            # Humidity is accepted only when its unit is percent.
            h_c: >-
              {%- if h_ent | string | length > 0 and is_number(states(h_ent)) and h_unit == '%' -%}
                {{ [states(h_ent) | float | round(1), 99.9] | min }}
              {%- endif -%}
            # Age of last_reported (not last_changed), preserving sub-minute precision.
            t_age: >-
              {%- if t_ent | string | length == 0 or states[t_ent] is none -%}
                999999
              {%- else -%}
                {{ (now() - states[t_ent].last_reported).total_seconds() / 60 }}
              {%- endif -%}
            h_age: >-
              {%- if h_ent | string | length == 0 or states[h_ent] is none -%}
                999999
              {%- else -%}
                {{ (now() - states[h_ent].last_reported).total_seconds() / 60 }}
              {%- endif -%}
            # skip = not configured, ok = valid, invalid = configured but not usable
            t_status: >-
              {%- if t_ent | string | length == 0 -%}skip
              {%- elif t_c_raw != '' and t_unit in ['°C', '℃', '°F', '℉', 'K'] and -20 <= t_c_raw | float <= 60
                       and (stale_minutes | int(0) == 0 or t_age | float <= stale_minutes | int(0)) -%}ok
              {%- else -%}invalid{%- endif -%}
            h_status: >-
              {%- if h_ent | string | length == 0 -%}skip
              {%- elif h_c != '' and h_unit == '%' and 0 <= states(h_ent) | float <= 100
                       and (stale_minutes | int(0) == 0 or h_age | float <= stale_minutes | int(0)) -%}ok
              {%- else -%}invalid{%- endif -%}
        - alias: Temperature
          choose:
            - conditions:
                - condition: template
                  value_template: "{{ t_status == 'ok' }}"
              sequence:
                - if:
                    - condition: template
                      value_template: "{{ restore_online }}"
                  then:
                    - action: mqtt.publish
                      data:
                        topic: "{{ set_topic }}"
                        qos: 1
                        retain: false
                        payload: '{{ {"source_" ~ slot ~ "_temperature_state": "online"} | tojson }}'
                    - delay:
                        seconds: "{{ write_delay }}"
                - action: mqtt.publish
                  data:
                    topic: "{{ set_topic }}"
                    qos: 1
                    retain: false
                    payload: '{{ {"source_" ~ slot ~ "_temperature": t_c | float} | tojson }}'
                - delay:
                    seconds: "{{ write_delay }}"
            - conditions:
                - condition: template
                  value_template: "{{ t_status == 'invalid' and t_fallback != 'none' }}"
              sequence:
                - action: mqtt.publish
                  data:
                    topic: "{{ set_topic }}"
                    qos: 1
                    retain: false
                    payload: '{{ {"source_" ~ slot ~ "_temperature_state": t_fallback} | tojson }}'
                - delay:
                    seconds: "{{ write_delay }}"
        - alias: Humidity
          choose:
            - conditions:
                - condition: template
                  value_template: "{{ h_status == 'ok' }}"
              sequence:
                - if:
                    - condition: template
                      value_template: "{{ restore_online }}"
                  then:
                    - action: mqtt.publish
                      data:
                        topic: "{{ set_topic }}"
                        qos: 1
                        retain: false
                        payload: '{{ {"source_" ~ slot ~ "_humidity_state": "online"} | tojson }}'
                    - delay:
                        seconds: "{{ write_delay }}"
                - action: mqtt.publish
                  data:
                    topic: "{{ set_topic }}"
                    qos: 1
                    retain: false
                    payload: '{{ {"source_" ~ slot ~ "_humidity": h_c | float} | tojson }}'
                - delay:
                    seconds: "{{ write_delay }}"
            - conditions:
                - condition: template
                  value_template: "{{ h_status == 'invalid' and h_fallback != 'none' }}"
              sequence:
                - action: mqtt.publish
                  data:
                    topic: "{{ set_topic }}"
                    qos: 1
                    retain: false
                    payload: '{{ {"source_" ~ slot ~ "_humidity_state": h_fallback} | tojson }}'
                - delay:
                    seconds: "{{ write_delay }}"