Lösung: Zigbee Repeater + Bluetooth Proxy auf einem ESP32-C6 mit ESPHome

Liebe Leute,

offiziell und eigentlich nicht möglich, aber man kann einen ESP32-C6-Zero (mit 4MB Flash) als Zigbee Repeater und Bluetooth-Proxy einsetzen - Radio-Timeslicing macht es möglich!
Ganz nebenbei reportet das Device natürlich in ESPHome, hält die WiFi-Verbindung und ist auch noch OTA-fähig!

Der YAML-Code unten ist auf den ESP32-C6 hin optimiert, kann aber auch auf anderen ESP32-Modulen einsetzen, die Zigbee + WiFi Coexistence zulassen.

Features

:white_check_mark: Dual‑Radio‑Setup mit Timeslicing (Zigbee + BLE)

:white_check_mark: Bluetooth wird beim Zigbee-Pairing automatisch deaktiviert

  • Während des Pairings wird BLE abgeschaltet (da beim Pairing oft längere Sende-/Empfangszeiten benötigt werden, als das Timeslicing zulässt)
  • Nach einem Pairing-Timeout oder im Normalbetrieb (nach erfolgreicher Verbindung mit dem Zigbee-Coordinator) wird BLE wieder aktiviert.

:white_check_mark: Zigbee-Heartbeat

  • Sendet alle 5 Minuten einen manuellen Zigbee-Report als Heartbeat ins Zigbee-Netz

:white_check_mark: Status-LED (hier: WS2812) mit State-Machine und Cache

  • WS2812-Status-LED (RMT RGB LED “Strip”)
  • State-Machine mit Musterlogik (Blink / Doppelblink / Puls) auf 250 ms Takt, inklusive Cache, um unnötige LED-Writes zu vermeiden.

:white_check_mark: Komplett über Home Assistant steuerbar

  • Konfguration über Steuer-/Service-Entitäten (z.B. Pairing, Reset, LED-Enable)
  • Diagnose-Entitäten (Zigbee connected, Bluetooth active, Operating mode, etc.)

:white_check_mark: OTA trotz großer Bibliotheken

  • Es wurde auf AP-Fallback und ein web_portal / captive verzichtet, um trotz großem Zigbee- und Bluetooth-Code die OTA-Funktionalität zu erhalten.
  • Dafür wurde in der Partitionstabelle fast schon das letzte Bit ausgereizt.

Status-LED-Codierung:

  • S1 Pairing & noch nicht verbunden → Orange blinkend
    [:orange_circle::orange_circle::black_circle::black_circle::orange_circle::orange_circle::black_circle::black_circle:]

  • S2.1 Normal & ZB verbunden & BT aktiv → Konstant Grün mit blauen Pulsen
    [:green_circle::green_circle::green_circle::blue_circle::green_circle::green_circle::green_circle::blue_circle:]

  • S2.2 Normal & ZB verbunden & BT inaktiv → Konstant Grün
    [:green_circle::green_circle::green_circle::green_circle::green_circle::green_circle::green_circle::green_circle:] ← LED-Status vorhanden, aber aktuell kann BT nicht getrennt deaktiviert werden - aber vielleicht möchte das jemand für sich implementieren :wink:

  • S3 Zigbee-Pairing fehlgeschlagen → Orangener Doppelblink
    [:orange_circle::black_circle::orange_circle::black_circle::black_circle::black_circle::orange_circle::black_circle::orange_circle:]

  • S4 Fehler → Rot
    [:red_circle::red_circle::red_circle::red_circle::red_circle::red_circle::red_circle::red_circle:]

Der Code…

ESPHome YAML:

substitutions:
  # =========================
  # Device identity
  # =========================
  node_name: zigbee-ble-esphome
  friendly_name: "ESP32-C6 Zigbee Router + BT Proxy"
  api_key: "YOUR_API_KEY"
  ota_pw: "YOUR_OTA_PASSWORD"

  # =========================
  # WiFi (no AP mode - 4MB...)
  # =========================
  wifi_ssid: !secret wifi_ssid
  wifi_password: !secret wifi_password

  # =========================
  # ESP32 platform
  # =========================
  board: esp32-c6-devkitc-1
  variant: esp32c6
  flash_size: 4MB

  # Zigbee partitions file (REQUIRED by luar123/zigbee_esphome validation)
  zigbee_partitions: "/config/esphome/part_ZB-BLE.csv"

  # =========================
  # External component pinning
  # =========================
  zigbee_repo_url: github://luar123/zigbee_esphome
  zigbee_components: [zigbee]

  # =========================
  # Status LED (WS2812 / addressable)
  # =========================
  status_led_pin: GPIO8
  status_led_rgb_order: RGB
  status_led_num_leds: "1"
  status_led_brightness: "0.5"

  # Color definitions (0.0 .. 1.0)
  # Blue
  col_blue_r: "0.00"
  col_blue_g: "0.00"
  col_blue_b: "1.00"
  # Orange
  col_orange_r: "1.00"
  col_orange_g: "0.50"
  col_orange_b: "0.00"
  # Red
  col_red_r: "1.00"
  col_red_g: "0.00"
  col_red_b: "0.00"

  # =========================
  # BLE scan timeslicing (shares radio time with 802.15.4)
  # =========================
  ble_scan_interval: 1250ms
  ble_scan_window: 150ms

  # Zigbee pairing timeout (should be > than network controller's join timer)
  pairing_timeout: 300s


# =========================
# Core
# =========================
esphome:
  name: ${node_name}
  friendly_name: ${friendly_name}
  name_add_mac_suffix: false

  on_boot:
    priority: 200
    then:
      # Start sane: normal mode. Let Zigbee stack settle first.
      - globals.set:
          id: device_mode
          value: "0"

      # Always start disconnected from UI perspective; will be set by sync later
      - globals.set:
          id: zb_connected
          value: "false"

      - script.execute: publish_mode_text
      - script.execute: apply_bt_policy
      - script.execute: apply_status_led

      # Give Zigbee some time to rejoin in non-factory-new reboot path
      - script.execute: boot_join_grace


esp32:
  board: ${board}
  variant: ${variant}
  flash_size: ${flash_size}
  partitions: ${zigbee_partitions}
  framework:
    type: esp-idf
    advanced:
      compiler_optimization: SIZE
      assertion_level: SILENT
      enable_lwip_assert: false

external_components:
  - source: ${zigbee_repo_url}
    components: ${zigbee_components}

wifi:
  ssid: ${wifi_ssid}
  password: ${wifi_password}
  power_save_mode: none
  use_address: ${node_name}
  reboot_timeout: 0s

logger:
  level: DEBUG
  hardware_uart: USB_SERIAL_JTAG
  logs:
    light: DEBUG

api:
  reboot_timeout: 0s
  encryption:
    key: ${api_key}

ota:
  platform: esphome
  password: ${ota_pw}


# =========================
# Bluetooth Proxy + BLE Tracker (timesliced)
# =========================
esp32_ble:
  id: ble
  enable_on_boot: false
  disable_bt_logs: true
  max_connections: 1
  max_notifications: 8

esp32_ble_tracker:
  id: ble_tracker
  scan_parameters:
    interval: ${ble_scan_interval}
    window: ${ble_scan_window}
    active: false
    continuous: true

bluetooth_proxy:
  active: false
  cache_services: false


# =========================
# Zigbee Router (luar123/zigbee_esphome)
# =========================
zigbee:
  id: zb
  router: true
  power_supply: 1
  components: none

  endpoints:
    - num: 1
      device_type: TEMPERATURE_SENSOR
      clusters:
        - id: TEMP_MEASUREMENT
          attributes:
            - attribute_id: 0x0
              type: S16
              report: true
              value: 2000       # initial value (20.00°C) - will be updated from sensor
              device: temp_internal
              scale: 100


  on_join:
    then:
      - logger.log: "[ZB] Joined network!"
      - globals.set:
          id: zb_connected
          value: "true"

      - if:
          condition:
            lambda: 'return id(device_mode) == 1;'
          then:
            - logger.log: "[ZB] Pairing succeeded -> switching to normal, re-enabling BLE"
            - script.execute: apply_status_led
            - delay: 2s
            - globals.set:
                id: device_mode
                value: "0"
            - script.execute: publish_mode_text
            - script.execute: apply_bt_policy
            - script.execute: apply_status_led
          else:
            - script.execute: publish_mode_text
            - script.execute: apply_status_led


# =========================
# Globals (state machine)
# =========================
globals:
  - id: device_mode
    type: int
    restore_value: false
    initial_value: "0"

  - id: zb_connected
    type: bool
    restore_value: false
    initial_value: "false"

  - id: status_led_enabled_g
    type: bool
    restore_value: true
    initial_value: "true"

  - id: blink_phase
    type: bool
    restore_value: false
    initial_value: "false"

  # LED pattern step (incremented every 250ms)
  - id: led_step
    type: int
    restore_value: false
    initial_value: "0"

  # LED cache (avoid re-sending same color every tick)
  - id: led_last_on
    type: bool
    restore_value: false
    initial_value: "false"

  - id: led_last_r
    type: float
    restore_value: false
    initial_value: "-1.0"

  - id: led_last_g
    type: float
    restore_value: false
    initial_value: "-1.0"

  - id: led_last_b
    type: float
    restore_value: false
    initial_value: "-1.0"

  # Cache last published mode to avoid spamming HA + logs
  - id: last_mode_int
    type: int
    restore_value: false
    initial_value: "-1"




# =========================
# Status LED (addressable WS2812 via RMT LED strip)
# =========================
light:
  - platform: esp32_rmt_led_strip
    id: status_led
    name: "${friendly_name} Status LED (internal)"
    internal: true
    pin: ${status_led_pin}
    num_leds: ${status_led_num_leds}
    rgb_order: ${status_led_rgb_order}
    chipset: ws2812
    restore_mode: ALWAYS_OFF
    default_transition_length: 0s
    flash_transition_length: 0s


# =========================
# Home Assistant entities
# =========================
switch:
  - platform: template
    id: status_led_enable_sw
    name: "Status LED"
    entity_category: config
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    turn_on_action:
      - logger.log: "[LED] Status LED enabled by user"
      - globals.set:
          id: status_led_enabled_g
          value: "true"
      - script.execute: apply_status_led
    turn_off_action:
      - logger.log: "[LED] Status LED disabled by user"
      - globals.set:
          id: status_led_enabled_g
          value: "false"
      - light.turn_off: status_led

sensor:
  # Interne Chip-Temperatur (ESP32-C6)
  - platform: internal_temperature
    id: temp_internal
    name: "Internal Temperature (local)"
    entity_category: diagnostic
    disabled_by_default: true
    update_interval: 60s
    filters:
      - delta: 0.1

button:
  - platform: restart
    name: "Reboot"
    icon: mdi:restart

  - platform: template
    id: zigbee_pairing_btn
    name: "Start Zigbee pairing"
    icon: mdi:zigbee
    entity_category: config
    on_press:
      - logger.log: "[PAIR] Pairing button pressed -> mode=pairing, BLE OFF, zigbee.reset"
      - globals.set:
          id: device_mode
          value: "1"
      - globals.set:
          id: zb_connected
          value: "false"
      - script.execute: publish_mode_text
      - script.execute: apply_bt_policy
      - script.execute: apply_status_led
      - zigbee.reset: zb
      - script.execute: pairing_timeout_guard

  - platform: template
    id: zigbee_factory_reset_btn
    name: "Zigbee Factory Reset"
    icon: mdi:delete-alert
    entity_category: config
    on_press:
      - logger.log: "[PAIR] Factory reset button pressed -> mode=pairing, BLE OFF, zigbee.reset"
      - globals.set:
          id: device_mode
          value: "1"
      - globals.set:
          id: zb_connected
          value: "false"
      - script.execute: publish_mode_text
      - script.execute: apply_bt_policy
      - script.execute: apply_status_led
      - zigbee.reset: zb
      - script.execute: pairing_timeout_guard

binary_sensor:
  - platform: template
    id: zigbee_connected_bs
    name: "Zigbee connected"
    icon: mdi:zigbee
    device_class: connectivity
    entity_category: diagnostic
    lambda: "return id(zb_connected);"
  
  - platform: template
    id: bluetooth_active_bs
    name: "Bluetooth active"
    icon: mdi:bluetooth
    device_class: connectivity
    entity_category: diagnostic
    lambda: "return id(ble).is_active();"

  - platform: template
    id: zb_connected_stable
    internal: true
    lambda: |-
      return id(zb).is_connected();
    filters:
      - delayed_on: 5s
      - delayed_off: 5s
    on_press:
      then:
        - globals.set: 
            id: zb_connected
            value: "true"
        - logger.log:
            level: INFO
            format: "[ZB] Connected (stable)"
        - delay: 2s
        - logger.log: "[ZB] Boot report: sending zigbee.report"
        - zigbee.report: zb
        # Exit pairing automatically when the network is really up
        - if:
            condition:
              lambda: 'return id(device_mode) == 1;'
            then:
              - globals.set:
                  id: device_mode
                  value: "0"
        - script.execute: refresh_outputs
    on_release:
      then:
        - globals.set:
            id: zb_connected
            value: "false"
        - logger.log:
            level: WARN
            format: "[ZB] Disconnected (stable)"
        - script.execute: refresh_outputs

text_sensor:
  - platform: template
    id: mode_text
    name: "Operating mode"
    icon: mdi:state-machine
    entity_category: diagnostic
    update_interval: never


# =========================
# Scripts (state machine + BT control)
# =========================
script:
  
  - id: refresh_outputs
    mode: restart
    then:
      - script.execute: publish_mode_text
      - script.execute: apply_bt_policy
      - script.execute: apply_status_led

  
  - id: publish_mode_text
    mode: restart
    then:
      - lambda: |-
          // Desired mode text
          std::string desired;
          switch (id(device_mode)) {
            case 0: desired = "normal"; break;
            case 1: desired = "pairing"; break;
            case 2: desired = "pairing_failed"; break;
            case 3: desired = "error"; break;
            default: desired = "error"; break;
          }

          // Hard guard #1: same numeric mode as last time
          // Hard guard #2: same text already published -> do nothing
          // (This makes it robust even if the script is called periodically.)
          if (id(last_mode_int) == id(device_mode) && id(mode_text).state == desired) {
            return;
          }

          id(last_mode_int) = id(device_mode);
          id(mode_text).publish_state(desired);

          ESP_LOGD("main", "[MODE] device_mode=%d zb_connected=%d led_enabled=%d",
                   id(device_mode),
                   id(zb_connected) ? 1 : 0,
                   id(status_led_enabled_g) ? 1 : 0);


  - id: ble_disable_all
    mode: restart
    then:
      - logger.log:
          level: INFO
          format: "[BLE] Disabling BLE: stop_scan -> ble.disable"
      - esp32_ble_tracker.stop_scan: ble_tracker
      - delay: 200ms
      - ble.disable:

  - id: ble_enable_and_scan
    mode: restart
    then:
      - logger.log:
          level: INFO
          format: "[BLE] Enabling BLE: ble.enable -> start_scan (interval=%s window=%s)"
          args:
            - "\"${ble_scan_interval}\""
            - "\"${ble_scan_window}\""
      - ble.enable:
      - delay: 200ms
      - esp32_ble_tracker.start_scan:
          id: ble_tracker
          continuous: true

  - id: apply_bt_policy
    mode: restart
    then:
      - if:
          condition:
            lambda: 'return id(device_mode) == 1;'
          then:
            # Pairing -> BLE has to be disabled
            - script.execute: ble_disable_all
          else:
            # Normal / pairing_failed / error -> BLE on again
            - script.execute: ble_enable_and_scan

  - id: pairing_timeout_guard
    mode: restart
    then:
      - logger.log: "[PAIR] Temporyry Test: Timeout guard started"
      - logger.log:
          level: DEBUG
          format: "[PAIR] Timeout guard started (%s). Current: mode=%d zb_connected=%d"
          args:
            - "\"${pairing_timeout}\""
            - "id(device_mode)"
            - "id(zb_connected) ? 1 : 0"
      - delay: ${pairing_timeout}
      - if:
          condition:
            lambda: 'return (id(device_mode) == 1) && (!id(zb_connected));'
          then:
            - logger.log:
                level: WARN
                format: "[PAIR] Timeout reached -> pairing_failed (mode=2), re-enable BLE"
            - globals.set:
                id: device_mode
                value: "2"
            - script.execute: publish_mode_text
            - script.execute: apply_bt_policy
            - script.execute: apply_status_led
          else:
            - logger.log:
                level: DEBUG
                format: "[PAIR] Timeout guard finished (no action). mode=%d zb_connected=%d"
                args:
                  - "id(device_mode)"
                  - "id(zb_connected) ? 1 : 0"


  - id: boot_join_grace
    mode: restart
    then:
      - delay: 15s
      - if:
          condition:
            lambda: 'return !id(zb_connected);'
          then:
            - logger.log:
                level: INFO
                format: "[ZB] Not connected after boot grace -> entering pairing mode"
            - globals.set:
                id: device_mode
                value: "1"
            - script.execute: refresh_outputs
            - script.execute: pairing_timeout_guard


  - id: apply_status_led
    mode: restart
    then:
      - lambda: |-
          // ==========================================================
          // LED coding
          //
          // S1: pairing & not connected  -> Orange blink (1Hz, on/off)
          // S3.1: normal & connected & BT active -> Green solid + short Blue pulse every ~2s
          // S3.2: normal & connected & BT inactive -> Green solid
          // S5: pairing_failed -> Orange double-blink (two short blinks then pause)
          // S6: error -> Red solid
          //
          // Notes:
          // - LED disabled switch suppresses output completely.
          // - Patterns are driven by led_step (250ms tick).
          // - Cache prevents redundant light updates.
          // ==========================================================

          // If user disabled status LED -> force OFF (but keep state machine alive)
          if (!id(status_led_enabled_g)) {
            if (id(led_last_on)) {
              id(status_led).turn_off().perform();
              id(led_last_on) = false;
            }
            return;
          }

          const int mode = id(device_mode);
          const bool zc  = id(zb_connected);
          const bool bt  = id(ble).is_active();   // BLE interface active (policy-controlled)

          // Desired output
          bool on = true;
          float r = ${col_red_r};
          float g = ${col_red_g};
          float b = ${col_red_b};

          // Pattern helpers
          const int step = id(led_step) & 0x0F;       // 0..15, repeats every 4s
          const int step8 = step & 0x07;             // 0..7, repeats every 2s

          // --- S6: error -> RED solid ---
          if (mode == 3) {
            r = ${col_red_r}; g = ${col_red_g}; b = ${col_red_b};
            on = true;
          }
          // --- S5: pairing_failed -> ORANGE double-blink ---
          else if (mode == 2) {
            // 2s cycle (8 steps @ 250ms):
            // step8: 0 ON, 1 OFF, 2 ON, 3 OFF, 4..7 OFF  -> double blink
            const bool pulse = (step8 == 0) || (step8 == 2);
            if (pulse) {
              r = ${col_orange_r}; g = ${col_orange_g}; b = ${col_orange_b};
              on = true;
            } else {
              on = false;
            }
          }
          // --- S1: pairing & not connected -> ORANGE blink (1Hz) ---
          else if (mode == 1 && !zc) {
            // 1Hz blink using 1s period:
            // 0..1 ON (500ms), 2..3 OFF (500ms) with 250ms tick => use step%4
            const bool blink_on = ((step & 0x03) < 2);
            if (blink_on) {
              r = ${col_orange_r}; g = ${col_orange_g}; b = ${col_orange_b};
              on = true;
            } else {
              on = false;
            }
          }
          // --- Normal/connected path (includes the short transitional "pairing+connected" too) ---
          else if (zc) {
            // Base: GREEN solid (everything OK)
            r = 0.0f; g = 1.0f; b = 0.0f;
            on = true;

            // S3.1: add short BLUE pulse if BT active
            // short pulse = 250ms every ~2s (step8==0)
            if (bt && step8 == 0) {
              r = ${col_blue_r}; g = ${col_blue_g}; b = ${col_blue_b};
            }
          }
          // --- Fallback: not connected but not in pairing (unexpected) -> RED solid ---
          else {
            r = ${col_red_r}; g = ${col_red_g}; b = ${col_red_b};
            on = true;
          }

          // Cache check (avoid redundant LED updates)
          const float eps = 0.0005f;
          bool changed = false;

          if (on != id(led_last_on)) {
            changed = true;
          } else if (on) {
            if (fabsf(r - id(led_last_r)) > eps || fabsf(g - id(led_last_g)) > eps || fabsf(b - id(led_last_b)) > eps) {
              changed = true;
            }
          }

          if (!changed) return;

          if (!on) {
            id(status_led).turn_off().perform();
          } else {
            auto call = id(status_led).turn_on();
            call.set_brightness(${status_led_brightness});
            call.set_rgb(r, g, b);
            call.perform();
          }

          id(led_last_on) = on;
          id(led_last_r)  = r;
          id(led_last_g)  = g;
          id(led_last_b)  = b;



# =========================
# Interval: drives blink cadence + keeps LED consistent
# =========================
interval:
  - interval: 250ms
    then:
      - lambda: |-
          // wrapping 0..15 (lower 4 bits) - no heap inflation and no overflow
          id(led_step) = (id(led_step) + 1) & 0x0F; 
      - script.execute: apply_status_led

  # Optional: periodic summary to avoid spamming every second
  - interval: 30s
    then:
      - logger.log:
          level: DEBUG
          format: "[HEARTBEAT] mode=%d zb_connected=%d bt_enabled=%d led_enabled=%d"
          args:
            - "id(device_mode)"
            - "id(zb_connected) ? 1 : 0"
            - "id(ble).is_active() ? 1 : 0"
            - "id(status_led_enabled_g) ? 1 : 0"

  - interval: 5min
    then:
      - if:
          condition:
            lambda: 'return id(zb_connected);'
          then:
            - logger.log: "[ZB] Sending Zigbee heartbeat..."
            - zigbee.report: zb

Die CSV mit der Partitions-Tabelle:

# Name,     Type, SubType, Offset,  Size,     Flags
nvs,        data, nvs,     ,        0x4000,
otadata,    data, ota,     ,        0x2000,
phy_init,   data, phy,     ,        0x1000,
app0,       app,  ota_0,   ,        0x1E0000,
app1,       app,  ota_1,   ,        0x1E0000,
zb_storage, data, fat,     ,        16K,
zb_fct,     data, fat,     ,        1K,

Viel Spaß damit!!

1 „Gefällt mir“