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
Dual‑Radio‑Setup mit Timeslicing (Zigbee + BLE)
- Zigbee Router auf ESP32-C6 via externer ESPHome-Component
(GitHub - luar123/zigbee_esphome: External zigbee component · GitHub) - BLE Tracking / Proxy-Grundlage via
esp32_ble_rackerundbluetooth_proxy - Timeslicing über Scan-Parameter (
interval/window), damit Zigbee und BLE sich die Funkzeit teilen
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.
Zigbee-Heartbeat
- Sendet alle 5 Minuten einen manuellen Zigbee-Report als Heartbeat ins Zigbee-Netz
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.
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.)
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
[






] -
S2.1 Normal & ZB verbunden & BT aktiv → Konstant Grün mit blauen Pulsen
[






] -
S2.2 Normal & ZB verbunden & BT inaktiv → Konstant Grün
[






] ← LED-Status vorhanden, aber aktuell kann BT nicht getrennt deaktiviert werden - aber vielleicht möchte das jemand für sich implementieren 
-
S3 Zigbee-Pairing fehlgeschlagen → Orangener Doppelblink
[







] -
S4 Fehler → Rot
[






]
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!!