Skip to content

Navigation Menu

Sign in
Sign up

Repository files navigation

ESP32-Coordinator (Zigbee NCP Firmware)

GitHub Release License: MIT

A high-performance, professional Zigbee Network Co-Processor (NCP) firmware designed for modern Espressif chips (ESP32-C5, ESP32-C6, ESP32-H2). This project transforms your ESP32-board into a robust Zigbee Coordinator tailored for seamless integration with Zigbee2MQTT (Z2M). The firmware is powered by the ZBOSS protocol, featuring support for OTA self-updates and compatibility with Green Power devices.


⚑ Flashing Methods

Choose one of the two methods below to flash your device.

Method 1: Web Installer (Easiest)

  1. Connect your ESP32-board to your computer via a USB cable.
  2. Open Google Chrome, Mozilla Firefox or Microsoft Edge and navigate to: biofieldua.github.io/esp-coordinator
  3. Follow the on-screen instructions to flash your device directly from the browser.

Method 2: Manual Flashing via CLI

Ensure you have the Espressif tool installed (esptool.py, included in ESP-IDF v5.5+ or available via pip install esptool).

➑️ First-Time Flash (or Factory Reset)

Use the *-factory.bin image. This writes the entire partition layout (including the bootloader) to the chip.

esptool.py --chip esp32c5 -p COM6 -b 460800 --before=default_reset --after=hard_reset write_flash --flash_mode dio --flash_freq 80m --flash_size detect 0x0 esp-coordinator-esp32c5-usb-ceramic-v1.0.2.7-factory.bin

(Adjust the --chip target and serial -p port according to your hardware).

πŸ”„ Firmware Update (Keep Your Zigbee Network Configuration)

Use the *-update.bin image. This overwrites the application code without resetting your Zigbee network data. You must flash it to both OTA slots:

esptool.py --chip esp32c5 -p COM6 -b 460800 write_flash 0x40000 esp-coordinator-esp32c5-usb-ceramic-v1.0.2.7-update.bin 0x180000 esp-coordinator-esp32c5-usb-ceramic-v1.0.2.7-update.bin

πŸŽ›οΈ Understanding Firmware Naming Conventions

The release page contains multiple binaries named by target parameters: esp-coordinator-[CHIP]-[INTERFACE]-[ANTENNA]-[VERSION]-[TYPE].bin

  • CHIP: esp32c5, esp32c6, or esp32h2.
  • INTERFACE: usb (direct USB-JTAG/Serial) or uart (default ESP32-board RX/TX pins without rts/cts).
  • ANTENNA Configurations:
    • ceramic (Default Antenna): Select this for standard boards. It uses whatever antenna is physically hardwired (PCB trace or Ceramic antenna). No software RF-switching is executed.
    • external (Software RF-Switch Enabled): Select this only if your board features a dedicated software-controlled RF switch (e.g., Seeed Studio XIAO ESP32-C6 or Waveshare ESP32-C5-Zero). It forces the firmware to shift RF output to the external IPEX/u.FL connector, while keeping the onboard antenna as default at startup.
  • TYPE: factory (for Factory Reset) or update (for Keep Existing Zigbee Network).

βš™οΈ Zigbee2MQTT Configuration

Add the following block to your Zigbee2MQTT configuration.yaml file.

serial:
 port: /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_... (Persistent ID)
 adapter: zboss
 baudrate: 500000
 rtscts: false
advanced:
 pan_id: GENERATE
 ext_pan_id: GENERATE
 network_key: GENERATE

πŸ” How to Find Your Persistent ID for USB connection on Linux

To find the exact string for the /dev/serial/by-id/ path, connect your ESP32-board via USB and run the following command in your Linux terminal:

ls -l /dev/serial/by-id/

Example Output:

lrwxrwxrwx 1 root root 13 Jun 14 18:31 usb-Espressif_USB_JTAG_serial_debug_unit_38:8D:B3:A1:56:DC-if00 -> ../../ttyACM0

Just copy the entire filename (e.g., usb-Espressif_USB_JTAG_serial_debug_unit_38:8D:B3:A1:56:DC-if00) and append it to /dev/serial/by-id/ inside your configuration.yaml.

πŸ“‹ Selecting the Correct Serial Port (port:)

The correct port depends entirely on how you connected the ESP32-board to your host system (e.g., Raspberry Pi, Orange Pi, or PC):

Connection Type Port Example Syntax Description
Direct USB (Native) /dev/ttyACM0 Standard virtual COM port when using the native ESP32 USB-JTAG/Serial interface.
Direct USB (Persistent ID) /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_XXXXXX Recommended for USB! Keeps the port identical even after host reboots. Replace XXXXXX with your chip's specific MAC-suffix.
Direct Hardware UART /dev/ttyS2 (or /dev/ttyS1, /dev/ttyAMA0) Used when connecting the host's hardware UART lines directly to the ESP32-board UART RX/TX pins.
Windows Users COM3 or COM5 or COM6 Check your Device Manager to locate the exact COM index.

πŸ“Œ Hardware UART Pinout (Default ESP32-board RX/TX pins)

If you connect ESP32-board via Direct Hardware UART (e.g., /dev/ttyS2 without flow control), use the following default pin map pre-configured in the firmware:

  • ESP32-C5:
    • TX: GPIO 11 ➑️ (Connect to Host RX)
    • RX: GPIO 12 ➑️ (Connect to Host TX)
  • ESP32-C6:
    • TX: GPIO 16 ➑️ (Connect to Host RX)
    • RX: GPIO 17 ➑️ (Connect to Host TX)
  • ESP32-H2:
    • TX: GPIO 24 ➑️ (Connect to Host RX)
    • RX: GPIO 23 ➑️ (Connect to Host TX)

⚠️ Note: Remember that TX goes to RX and RX goes to TX. A common ground (GND) connection between the ESP32-board and your host board is strictly required.


πŸ”„ Zigbee2MQTT OTA Updates Integration

ESP32-Coordinator natively supports OTA (Over-The-Air) self-updates driven directly from the Zigbee2MQTT dashboard. To activate this feature, you must install an External Extension.

  1. Open your Zigbee2MQTT Frontend Dashboard.
  2. Navigate to: Settings ➑️ Dev console ➑️ External Extensions.
  3. Create new extension with the following details:
    • Name: coordinator_ota_ext.mjs
    • Code: Copy and paste the full script block below:
import fs from "fs";
import os from "os";
import path from "path";
import https from "https";
import { getTimeClusterAttributes, Zcl } from "zigbee-herdsman";
export default class OTACoordinatorExtension {
 constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension, settings, logger) {
 this.zigbee = zigbee;
 this.logger = logger;
 this.otaSettings = settings.get().ota || {};
 this.onMessageBound = this.onMessage.bind(this);
 this.githubRepo = "BioFieldUA/esp-coordinator";
 this.userAgent = "Zigbee2MQTT-OTA-Client";
 this.tempFirmwarePath = path.join(os.tmpdir(), "coordinator-update.zigbee");
 this.isUpdating = false;
 }
 parseVersionToUint32(verStr) {
 const parts = verStr.replace(/^v/, '').split('.').map(p => parseInt(p, 10));
 if (parts.length !== 4 || parts.some(isNaN)) {
 throw new Error(`Invalid version format: ${verStr}`);
 }
 return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
 }
 fetchJson(url) {
 return new Promise((resolve, reject) => {
 const options = {headers: {"User-Agent": this.userAgent, "Accept": "application/vnd.github+json"}};
 https.get(url, options, (res) => {
 let data = "";
 res.on("data", (chunk) => data += chunk);
 res.on("end", () => {
 try { resolve(JSON.parse(data)); }
 catch (e) { reject(e); }
 });
 }).on("error", reject);
 });
 }
 downloadFile(version, url, destPath) {
 return new Promise((resolve, reject) => {
 const request = (targetUrl) => {
 const options = {headers: {"User-Agent": this.userAgent}};
 https.get(targetUrl, options, (response) => {
 if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
 return request(response.headers.location);
 }
 if (response.statusCode !== 200) {
 return reject(new Error(`Failed to download file. Status Code: ${response.statusCode}`));
 }
 const file = fs.createWriteStream(destPath);
 response.pipe(file);
 file.on("finish", () => file.close((err) => err ? reject(err) : resolve(version)));
 file.on("error", (err) => { fs.unlink(destPath, () => {}); reject(err); });
 }).on("error", reject);
 };
 request(url);
 });
 }
 onMessage(msg) {
 if (msg?.device?.type !== "Coordinator" || msg.type !== "attributeReport" || !msg.data) return;
 const extLogger = this.logger;
 if (msg.cluster === "genTime" && msg.data.time) {
 const { time, timeStatus, timeZone, dstStart, dstEnd, dstShift, validUntilTime } = getTimeClusterAttributes();
 if (time !== msg.data.time) {
 msg.endpoint?.write("genTime", { time, timeStatus, timeZone, dstStart, dstEnd, dstShift, validUntilTime }, "queue")
 .catch((err) => {
 extLogger.error(`[Automation] Failed to queue time attributes: ${err.message}`);
 });
 }
 }
 if (this.isUpdating || msg.cluster !== "genOta" || !msg.data.currentFileVersion || !msg.data.manufacturerId || !msg.data.imageTypeId || !msg.data.currentZigbeeStackVersion || !msg.data.minimumBlockReqDelay) return;
 this.isUpdating = true;
 const source = {
 downgrade: false,
 url: this.tempFirmwarePath
 };
 this.otaSettings.default_maximum_data_size = msg.data.minimumBlockReqDelay;
 const dataSettings = {
 requestTimeout: this.otaSettings.image_block_request_timeout ?? 150000,
 responseDelay: this.otaSettings.image_block_response_delay ?? 250,
 baseSize: this.otaSettings.default_maximum_data_size ?? 50,
 };
 const queryNextImagePayload = {
 fieldControl: 1,
 manufacturerCode: msg.data.manufacturerId,
 imageType: msg.data.imageTypeId,
 fileVersion: msg.data.currentFileVersion,
 hardwareVersion: msg.data.currentZigbeeStackVersion
 };
 const tsn = msg.meta?.zclTransactionSequenceNumber || undefined;
 const originalFindMatchingOtaImage = msg.device.findMatchingOtaImage;
 this.fetchJson(`https://api.github.com/repos/${this.githubRepo}/releases/latest`).then((latestRelease) => {
 if (!latestRelease?.tag_name || !latestRelease.assets) {
 throw new Error("Invalid GitHub API response");
 }
 const onlineVersion = this.parseVersionToUint32(latestRelease.tag_name);
 extLogger.info(`[Automation] Latest Coordinator firmware is ${latestRelease.tag_name} (${onlineVersion}).`);
 if (onlineVersion <= msg.data.currentFileVersion) {
 throw { type: "NO_UPDATE" };
 }
 const asset = latestRelease.assets.find(a => a.name?.endsWith('.zigbee') && parseInt(a.name.match(/-hw(?<hw>[0-9a-fA-F]{4})/)?.groups?.hw, 16) === msg.data.currentZigbeeStackVersion);
 if (!asset?.browser_download_url || !asset.name) {
 throw new Error(`Coordinator Release ${latestRelease.tag_name} is missing a compiled *.zigbee file for Hardware Version 0x${msg.data.currentZigbeeStackVersion.toString(16).toUpperCase().padStart(4, '0')}.`);
 }
 return this.downloadFile(onlineVersion, asset.browser_download_url, source.url);
 }).then((onlineVersion) => {
 msg.device.findMatchingOtaImage = async function (src, curr, extra) {
 if (this.type !== 'Coordinator') return originalFindMatchingOtaImage.call(this, src, curr, extra);
 return { url: source.url, imageType: queryNextImagePayload.imageType, manufacturerCode: queryNextImagePayload.manufacturerCode, fileVersion: onlineVersion, force: true };
 };
 return msg.device.updateOta(
 source,
 queryNextImagePayload,
 tsn,
 {},
 (progress, remaining) => extLogger.info(`[Automation] Coordinator updating progress: ${progress.toFixed(1)}%`),
 dataSettings,
 msg.endpoint
 );
 }).then(([from, to]) => {
 extLogger.info(`[Automation] Coordinator successfully updated (v.${from.fileVersion} => v.${to.fileVersion})`);
 }).catch((err) => {
 if (err && err.type === "NO_UPDATE") {
 extLogger.info("[Automation] Coordinator firmware is up to date.");
 } else {
 extLogger.error(`[Automation] Coordinator OTA Update Error: ${err.message}`);
 }
 if (!fs.existsSync(source.url)) {
 msg.endpoint?.commandResponse("genOta", "queryNextImageResponse", { status: Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, tsn).catch(() => {});
 }
 }).finally(() => {
 if (fs.existsSync(source.url)) fs.unlink(source.url, () => {});
 msg.device.findMatchingOtaImage = originalFindMatchingOtaImage;
 this.isUpdating = false;
 });
 }
 start() {
 const controller = this.zigbee.zhController;
 if (!controller) {
 return;
 }
 try {
 controller.off("message", this.onMessageBound);
 controller.on("message", this.onMessageBound);
 const port = controller.adapter?.driver?.port;
 if (port && typeof port.onPortClose === "function" && !port.__patched) {
 port.__patched = true;
 const extLogger = this.logger;
 const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 const activePort = port.serialPort;
 const rawPath = port.portOptions?.path;
 if (activePort && rawPath) {
 activePort.removeAllListeners("close");
 const oldOnPortClose = port.onPortClose;
 const portCloseCallback = async (err) => {
 oldOnPortClose.call(port, err);
 if (err) {
 await port.stop();
 (async () => {
 while (true) {
 await wait(3000);
 extLogger.info("[Automation] Checking if Coordinator is reconnected...");
 try {
 if (fs.existsSync(rawPath)) {
 extLogger.info(`[Automation] Coordinator detected at ${rawPath}. Restarting Zigbee2MQTT...`);
 await wait(2000);
 process.exit(1);
 }
 } catch (checkError) {
 extLogger.error(`[Automation] Coordinator still unavailable: ${checkError.message}`);
 }
 }
 })();
 }
 };
 port.onPortClose = portCloseCallback;
 activePort.once("close", portCloseCallback);
 } else {
 extLogger.info("[Automation] Failed to initialize Serial Port at extension startup");
 }
 }
 this.logger.info("[Automation] OTA Engine for Coordinator Initialized");
 } catch (error) {
 this.logger.error(`[Automation] Failed to initialize OTA Engine for Coordinator: ${error.message}`);
 }
 }
 stop() {
 const controller = this.zigbee.zhController;
 if (controller) {
 controller.off("message", this.onMessageBound);
 }
 }
};
  1. Save changes and restart Zigbee2MQTT.

πŸ”‹ Green Power Devices (Kinetic Switches)

This firmware includes full support for battery-free Green Power energy-harvesting hardware (such as the Moes Green Power kinetic 1/2/3-gang wireless switch). To bind and declare these custom switches, add the following handlers:

Step 1: External Converter (Declaration)

  1. Open your Zigbee2MQTT Frontend Dashboard.
  2. Go to Settings ➑️ Dev console ➑️ External Converters.
  3. Create new converter:
    • Name: gp_moes_switch_ext.mjs
    • Code:
import {genericGreenPower} from "zigbee-herdsman-converters/lib/modernExtend";
import {presets, access} from "zigbee-herdsman-converters/lib/exposes";
import {hasAlreadyProcessedMessage} from "zigbee-herdsman-converters/lib/utils";
const GP_COMMANDS = {
 16: "toggle_1",
 17: "toggle_2",
 18: "toggle_3",
 24: "toggle_1",
 25: "toggle_2",
 26: "toggle_3",
 32: "toggle_1",
 33: "toggle_2",
 34: "toggle_3",
};
const baseExtend = genericGreenPower();
baseExtend.exposes = [
 presets.action(["toggle_1", "toggle_2", "toggle_3"]),
 presets.list("payload", access.STATE, presets.numeric("payload", access.STATE).withDescription("Byte")).withDescription("Payload of the command"),
];
baseExtend.options = [
 presets.text("target_toggle_1", access.SET).withLabel("Object or Group").withDescription("Friendly Name for Button 1 (toggle_1)").withCategory("config"),
 presets.text("target_toggle_2", access.SET).withLabel("Object or Group").withDescription("Friendly Name for Button 2 (toggle_2)").withCategory("config"),
 presets.text("target_toggle_3", access.SET).withLabel("Object or Group").withDescription("Friendly Name for Button 3 (toggle_3)").withCategory("config"),
];
baseExtend.fromZigbee[0].convert = (model, msg, publish, options, meta) => {
 const commandID = msg.data?.commandID;
 if (!commandID || commandID >= 0xe0 || hasAlreadyProcessedMessage(msg, model, msg.data.frameCounter, `${msg.device?.ieeeAddr}_${commandID}`)) return;
 const gpdfCommandStr = GP_COMMANDS[commandID];
 const payloadBuf = "raw" in msg.data.commandFrame ? msg.data.commandFrame.raw : undefined;
 return {
 action: gpdfCommandStr ?? `unknown_${commandID}`,
 payload: payloadBuf?.length > 0 ? Array.from(payloadBuf) : [],
 };
};
export default {
 fingerprint: [{modelID: "GreenPower_2", ieeeAddr: /^0x00000000........$/}],
 model: "ZT-B-EU2",
 vendor: "Moes",
 description: "Green Power kinetic 1/2/3-gang wireless switch",
 extend: [baseExtend],
};
  1. Save changes.

Step 2: External Extension (Control Logic)

  1. Open your Zigbee2MQTT Frontend Dashboard.
  2. Go to Settings ➑️ Dev console ➑️ External Extensions.
  3. Create new extension:
    • Name: gp_switch_light_ext.mjs
    • Code:
export default class MoesSwitchLightExtension {
 constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension, settings, logger) {
 this.mqtt = mqtt;
 this.eventBus = eventBus;
 this.logger = logger;
 this.allowedActions = new Set(["toggle_1", "toggle_2", "toggle_3"]);
 this.baseTopic = settings.get()?.mqtt?.base_topic || "zigbee2mqtt";
 }
 start() {
 this.eventBus.on("stateChange", this.onStateChange.bind(this), this);
 this.logger.info("[Automation] Moes GreenPower Multi-Switch Linker Initialized");
 }
 async onStateChange(data) {
 if (!data.update?.action || !this.allowedActions.has(data.update.action) || !data.entity?.options) {
 return;
 }
 const target = data.entity.options[`target_${data.update.action}`];
 if (target) {
 this.mqtt.onMessage(`${this.baseTopic}/${target.trim()}/set`, JSON.stringify({ state: "TOGGLE" }));
 }
 }
 stop() {
 this.eventBus.removeListeners(this);
 }
};
  1. Save changes and restart Zigbee2MQTT to load both scripts.

🀝 Contributing & Support

If you encounter bugs, missing chip parameters, or want to suggest improvements, feel free to open an Issue or submit a Pull Request!


πŸ“„ License

This project is licensed under the PolyForm Strict License 1.0.0 .

  • Non-commercial use, testing, and personal research are permitted.
  • Commercial production, integration into commercial platforms, or sales require a dedicated license.

For commercial inquiries, please contact: biofield.com.ua@gmail.com

About

ESP32C5 / ESP32C6 / ESP32H2 Zigbee Coordinator firmware powered by ZBOSS protocol with native zigbee2mqtt support, OTA upgrade support, and Green Power compatibility.

Topics

Resources

Stars

8 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /