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.
Choose one of the two methods below to flash your device.
- Connect your ESP32-board to your computer via a USB cable.
- Open Google Chrome, Mozilla Firefox or Microsoft Edge and navigate to: biofieldua.github.io/esp-coordinator
- Follow the on-screen instructions to flash your device directly from the browser.
Ensure you have the Espressif tool installed (esptool.py, included in ESP-IDF v5.5+ or available via pip install esptool).
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).
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.binThe release page contains multiple binaries named by target parameters:
esp-coordinator-[CHIP]-[INTERFACE]-[ANTENNA]-[VERSION]-[TYPE].bin
- CHIP:
esp32c5,esp32c6, oresp32h2. - INTERFACE:
usb(direct USB-JTAG/Serial) oruart(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) orupdate(for Keep Existing Zigbee Network).
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: GENERATETo 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.
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. |
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)
- TX:
- ESP32-C6:
- TX:
GPIO 16➡️ (Connect to Host RX) - RX:
GPIO 17➡️ (Connect to Host TX)
- TX:
- ESP32-H2:
- TX:
GPIO 24➡️ (Connect to Host RX) - RX:
GPIO 23➡️ (Connect to Host TX)
- 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.
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.
- Open your Zigbee2MQTT Frontend Dashboard.
- Navigate to: Settings ➡️ Dev console ➡️ External Extensions.
- Create new extension with the following details:
- Name:
coordinator_ota_ext.mjs - Code: Copy and paste the full script block below:
- Name:
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);
}
}
};- Save changes and restart Zigbee2MQTT.
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:
- Open your Zigbee2MQTT Frontend Dashboard.
- Go to Settings ➡️ Dev console ➡️ External Converters.
- Create new converter:
- Name:
gp_moes_switch_ext.mjs - Code:
- Name:
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],
};- Save changes.
- Open your Zigbee2MQTT Frontend Dashboard.
- Go to Settings ➡️ Dev console ➡️ External Extensions.
- Create new extension:
- Name:
gp_switch_light_ext.mjs - Code:
- Name:
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);
}
};- Save changes and restart Zigbee2MQTT to load both scripts.
If you encounter bugs, missing chip parameters, or want to suggest improvements, feel free to open an Issue or submit a Pull Request!
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