{"id":50753288,"url":"https://github.com/biofieldua/esp-coordinator","last_synced_at":"2026-06-16T17:00:30.118Z","repository":{"id":362746046,"uuid":"1259829709","full_name":"BioFieldUA/esp-coordinator","owner":"BioFieldUA","description":"ESP32C5 / ESP32C6 Zigbee Coordinator firmware powered by ZBOSS protocol with native zigbee2mqtt support, OTA upgrade support, and Green Power compatibility.","archived":false,"fork":false,"pushed_at":"2026-06-11T02:27:24.000Z","size":2635,"stargazers_count":5,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-06-11T03:20:19.544Z","etag":null,"topics":["coordinator","esp-idf","esp-web-tools","esp32-c5","esp32-c6","esp32c5","esp32c6","firmware","green-power","home-assistant","iot-device","open-source","ota-updates","smart-home","xiao","zboss","zigbee","zigbee2mqtt"],"latest_commit_sha":null,"homepage":"https://biofieldua.github.io/esp-coordinator/","language":"C++","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/BioFieldUA.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"custom":["https://etherscan.io/address/0x9161918687B8b849e016A137912Ae2B2D7761F21"]}},"created_at":"2026-06-04T22:46:47.000Z","updated_at":"2026-06-11T02:27:27.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/BioFieldUA/esp-coordinator","commit_stats":null,"previous_names":["biofieldua/esp-coordinator"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/BioFieldUA/esp-coordinator","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BioFieldUA%2Fesp-coordinator","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BioFieldUA%2Fesp-coordinator/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BioFieldUA%2Fesp-coordinator/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BioFieldUA%2Fesp-coordinator/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/BioFieldUA","download_url":"https://codeload.github.com/BioFieldUA/esp-coordinator/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BioFieldUA%2Fesp-coordinator/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34415248,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-16T02:00:06.860Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["coordinator","esp-idf","esp-web-tools","esp32-c5","esp32-c6","esp32c5","esp32c6","firmware","green-power","home-assistant","iot-device","open-source","ota-updates","smart-home","xiao","zboss","zigbee","zigbee2mqtt"],"created_at":"2026-06-11T03:00:52.300Z","updated_at":"2026-06-16T17:00:30.112Z","avatar_url":"https://github.com/BioFieldUA.png","language":"C++","funding_links":["https://etherscan.io/address/0x9161918687B8b849e016A137912Ae2B2D7761F21"],"categories":[],"sub_categories":[],"readme":"# ESP32-Coordinator (Zigbee NCP Firmware)\n\n[![GitHub Release](https://img.shields.io/github/v/release/BioFieldUA/esp-coordinator)](https://github.com/BioFieldUA/esp-coordinator/releases/latest)\n[![License: MIT](https://img.shields.io/badge/License-PolyForm_Strict_1.0.0-yellow.svg)](./LICENSE.txt)\n\nA high-performance, professional Zigbee Network Co-Processor (NCP) firmware designed for modern Espressif chips (**ESP32-C5, ESP32-C6, ESP32-H2**).\nThis project transforms your ESP32-board into a robust **Zigbee Coordinator** tailored for seamless integration with **Zigbee2MQTT (Z2M)**.\nThe firmware is powered by the **ZBOSS** protocol, featuring support for **OTA** self-updates and compatibility with **Green Power** devices.\n\n---\n\n## ⚡ Flashing Methods\n\nChoose **one** of the two methods below to flash your device. \n\n### Method 1: Web Installer (Easiest)\n1. Connect your ESP32-board to your computer via a USB cable.\n2. Open Google Chrome, Mozilla Firefox or Microsoft Edge and navigate to: **[biofieldua.github.io/esp-coordinator](https://biofieldua.github.io/esp-coordinator)**\n3. Follow the on-screen instructions to flash your device directly from the browser.\n\n### Method 2: Manual Flashing via CLI\nEnsure you have the Espressif tool installed (`esptool.py`, included in ESP-IDF v5.5+ or available via `pip install esptool`).\n\n#### ➡️ First-Time Flash (or Factory Reset)\nUse the `*-factory.bin` image. This writes the entire partition layout (including the bootloader) to the chip.\n```bash\nesptool.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\n```\n*(Adjust the `--chip` target and serial `-p` port according to your hardware)*.\n\n#### 🔄 Firmware Update (Keep Your Zigbee Network Configuration)\nUse the `*-update.bin` image. This overwrites the application code without resetting your Zigbee network data. **You must flash it to both OTA slots:**\n```bash\nesptool.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\n```\n\n---\n\n## 🎛️ Understanding Firmware Naming Conventions\n\nThe release page contains multiple binaries named by target parameters:\n`esp-coordinator-[CHIP]-[INTERFACE]-[ANTENNA]-[VERSION]-[TYPE].bin`\n\n* **CHIP**: `esp32c5`, `esp32c6`, or `esp32h2`.\n* **INTERFACE**: `usb` (direct USB-JTAG/Serial) or `uart` (default ESP32-board RX/TX pins without rts/cts).\n* **ANTENNA Configurations**:\n  * `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.\n  * `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.\n* **TYPE**: `factory` (for Factory Reset) or `update` (for Keep Existing Zigbee Network).\n\n---\n\n## ⚙️ Zigbee2MQTT Configuration\n\nAdd the following block to your Zigbee2MQTT `configuration.yaml` file. \n\n```yaml\nserial:\n  port: /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_... (Persistent ID)\n  adapter: zboss\n  baudrate: 500000\n  rtscts: false\nadvanced:\n  pan_id: GENERATE\n  ext_pan_id: GENERATE\n  network_key: GENERATE\n```\n\n### 🔍 How to Find Your **Persistent ID** for USB connection on Linux\nTo 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:\n```bash\nls -l /dev/serial/by-id/\n```\n**Example Output:**\n```text\nlrwxrwxrwx 1 root root 13 Jun 14 18:31 usb-Espressif_USB_JTAG_serial_debug_unit_38:8D:B3:A1:56:DC-if00 -\u003e ../../ttyACM0\n```\nJust 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`.\n\n### 📋 Selecting the Correct Serial Port (`port:`)\nThe correct port depends entirely on how you connected the ESP32-board to your host system (e.g., Raspberry Pi, Orange Pi, or PC):\n\n| Connection Type | Port Example Syntax | Description |\n| :--- | :--- | :--- |\n| **Direct USB (Native)** | `/dev/ttyACM0` | Standard virtual COM port when using the native ESP32 USB-JTAG/Serial interface. |\n| **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. |\n| **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. |\n| **Windows Users** | `COM3` or `COM5` or `COM6` | Check your Device Manager to locate the exact COM index. |\n\n### 📌 Hardware UART Pinout (Default ESP32-board RX/TX pins)\nIf 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:\n* **ESP32-C5**:\n  * **TX**: `GPIO 11` ➡️ *(Connect to Host RX)*\n  * **RX**: `GPIO 12` ➡️ *(Connect to Host TX)*\n* **ESP32-C6**:\n  * **TX**: `GPIO 16` ➡️ *(Connect to Host RX)*\n  * **RX**: `GPIO 17` ➡️ *(Connect to Host TX)*\n* **ESP32-H2**:\n  * **TX**: `GPIO 24` ➡️ *(Connect to Host RX)*\n  * **RX**: `GPIO 23` ➡️ *(Connect to Host TX)*\n\u003e ⚠️ **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.\n\n---\n\n## 🔄 Zigbee2MQTT OTA Updates Integration\n\nESP32-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**.\n\n1. Open your Zigbee2MQTT Frontend Dashboard.\n2. Navigate to: **Settings** ➡️ **Dev console** ➡️ **External Extensions**.\n3. Create new extension with the following details:\n   * **Name**: `coordinator_ota_ext.mjs`\n   * **Code**: Copy and paste the full script block below:\n\n```javascript\nimport fs from \"fs\";\nimport os from \"os\";\nimport path from \"path\";\nimport https from \"https\";\nimport { Zcl } from \"zigbee-herdsman\";\n\nexport default class OTACoordinatorExtension {\n    constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension, settings, logger) {\n        this.zigbee = zigbee;\n        this.logger = logger;\n        this.otaSettings = settings.get().ota || {};\n        this.onMessageBound = this.onMessage.bind(this);\n        this.githubRepo = \"BioFieldUA/esp-coordinator\";\n        this.userAgent = \"Zigbee2MQTT-OTA-Client\";\n        this.tempFirmwarePath = path.join(os.tmpdir(), \"coordinator-update.zigbee\");\n        this.isUpdating = false;\n    }\n\n    parseVersionToUint32(verStr) {\n        const parts = verStr.replace(/^v/, '').split('.').map(p =\u003e parseInt(p, 10));\n        if (parts.length !== 4 || parts.some(isNaN)) {\n            throw new Error(`Invalid version format: ${verStr}`);\n        }\n        return (parts[0] \u003c\u003c 24) | (parts[1] \u003c\u003c 16) | (parts[2] \u003c\u003c 8) | parts[3];\n    }\n\n    fetchJson(url) {\n        return new Promise((resolve, reject) =\u003e {\n            const options = {headers: {\"User-Agent\": this.userAgent, \"Accept\": \"application/vnd.github+json\"}};\n            https.get(url, options, (res) =\u003e {\n                let data = \"\";\n                res.on(\"data\", (chunk) =\u003e data += chunk);\n                res.on(\"end\", () =\u003e {\n                    try { resolve(JSON.parse(data)); }\n                    catch (e) { reject(e); }\n                });\n            }).on(\"error\", reject);\n        });\n    }\n\n    downloadFile(version, url, destPath) {\n        return new Promise((resolve, reject) =\u003e {\n            const request = (targetUrl) =\u003e {\n                const options = {headers: {\"User-Agent\": this.userAgent}};\n                https.get(targetUrl, options, (response) =\u003e {\n                    if (response.statusCode \u003e= 300 \u0026\u0026 response.statusCode \u003c 400 \u0026\u0026 response.headers.location) {\n                        return request(response.headers.location);\n                    }\n                    if (response.statusCode !== 200) {\n                        return reject(new Error(`Failed to download file. Status Code: ${response.statusCode}`));\n                    }\n                    const file = fs.createWriteStream(destPath);\n                    response.pipe(file);\n                    file.on(\"finish\", () =\u003e file.close((err) =\u003e err ? reject(err) : resolve(version)));\n                    file.on(\"error\", (err) =\u003e { fs.unlink(destPath, () =\u003e {}); reject(err); });\n                }).on(\"error\", reject);\n            };\n            request(url);\n        });\n    }\n\n    onMessage(msg) {\n        if (this.isUpdating || msg?.device.type !== \"Coordinator\" || msg.cluster !== \"genOta\" || msg.type !== \"attributeReport\" || !msg.data?.currentFileVersion || !msg.data.manufacturerId || !msg.data.imageTypeId || !msg.data.currentZigbeeStackVersion || !msg.data.minimumBlockReqDelay) return;\n        this.isUpdating = true;\n        const extLogger = this.logger;\n        this.otaSettings.default_maximum_data_size = msg.data.minimumBlockReqDelay;\n        const source = {\n            downgrade: false,\n            url: this.tempFirmwarePath\n        };\n        const dataSettings = {\n            requestTimeout: this.otaSettings.image_block_request_timeout ?? 150000,\n            responseDelay: this.otaSettings.image_block_response_delay ?? 250,\n            baseSize: this.otaSettings.default_maximum_data_size ?? 50,\n        };\n        const queryNextImagePayload = {\n            fieldControl: 1,\n            manufacturerCode: msg.data.manufacturerId,\n            imageType: msg.data.imageTypeId,\n            fileVersion: msg.data.currentFileVersion,\n            hardwareVersion: msg.data.currentZigbeeStackVersion\n        };\n        const tsn = msg.meta?.zclTransactionSequenceNumber || undefined;\n        const originalFindMatchingOtaImage = msg.device.findMatchingOtaImage;\n        this.fetchJson(`https://api.github.com/repos/${this.githubRepo}/releases/latest`).then((latestRelease) =\u003e {\n            if (!latestRelease?.tag_name || !latestRelease.assets) {\n                throw new Error(\"Invalid GitHub API response\");\n            }\n            const onlineVersion = this.parseVersionToUint32(latestRelease.tag_name);\n            extLogger.info(`[Automation] Latest Coordinator firmware is ${latestRelease.tag_name} (${onlineVersion}).`);\n            if (onlineVersion \u003c= msg.data.currentFileVersion) {\n                throw { type: \"NO_UPDATE\" };\n            }\n            const asset = latestRelease.assets.find(a =\u003e a.name?.endsWith('.zigbee') \u0026\u0026 parseInt(a.name.match(/-hw(?\u003chw\u003e[0-9a-fA-F]{4})/)?.groups?.hw, 16) === msg.data.currentZigbeeStackVersion);\n            if (!asset?.browser_download_url || !asset.name) {\n                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')}.`);\n            }\n            return this.downloadFile(onlineVersion, asset.browser_download_url, source.url);\n        }).then((onlineVersion) =\u003e {\n            msg.device.findMatchingOtaImage = async function (src, curr, extra) {\n                if (this.type !== 'Coordinator') return originalFindMatchingOtaImage.call(this, src, curr, extra);\n                return { url: source.url, imageType: queryNextImagePayload.imageType, manufacturerCode: queryNextImagePayload.manufacturerCode, fileVersion: onlineVersion, force: true };\n            };\n            return msg.device.updateOta(\n                source,\n                queryNextImagePayload,\n                tsn,\n                {},\n                (progress, remaining) =\u003e extLogger.info(`[Automation] Coordinator updating progress: ${progress.toFixed(1)}%`),\n                dataSettings,\n                msg?.endpoint\n            );\n        }).then(([from, to]) =\u003e {\n            extLogger.info(`[Automation] Coordinator successfully updated (v.${from.fileVersion} =\u003e v.${to.fileVersion})`);\n        }).catch((err) =\u003e {\n            if (err \u0026\u0026 err.type === \"NO_UPDATE\") {\n                extLogger.info(\"[Automation] Coordinator firmware is up to date.\");\n            } else {\n                extLogger.error(`[Automation] Coordinator OTA Update Error: ${err.message}`);\n            }\n            if (!fs.existsSync(source.url)) {\n                msg.endpoint.commandResponse(\"genOta\", \"queryNextImageResponse\", { status: Zcl.Status.NO_IMAGE_AVAILABLE }, undefined, tsn).catch(() =\u003e {});\n            }\n        }).finally(() =\u003e {\n            if (fs.existsSync(source.url)) fs.unlink(source.url, () =\u003e {});\n            msg.device.findMatchingOtaImage = originalFindMatchingOtaImage;\n            this.isUpdating = false;\n        });\n    }\n\n    start() {\n        const controller = this.zigbee.zhController;\n        if (!controller) {\n            return;\n        }\n        try {\n            controller.off(\"message\", this.onMessageBound);\n            controller.on(\"message\", this.onMessageBound);\n            const port = controller.adapter?.driver?.port;\n            if (port \u0026\u0026 typeof port.onPortClose === \"function\" \u0026\u0026 !port.__patched) {\n                port.__patched = true;\n                const extLogger = this.logger;\n                const wait = (ms) =\u003e new Promise((resolve) =\u003e setTimeout(resolve, ms));\n                const activePort = port.serialPort;\n                const rawPath = port.portOptions?.path;\n                if (activePort \u0026\u0026 rawPath) {\n                    activePort.removeAllListeners(\"close\");\n                    const oldOnPortClose = port.onPortClose;\n                    const portCloseCallback = async (err) =\u003e {\n                        oldOnPortClose.call(port, err);\n                        if (err) {\n                            await port.stop();\n                            (async () =\u003e {\n                                while (true) {\n                                    await wait(3000);\n                                    extLogger.info(\"[Automation] Checking if Coordinator is reconnected...\");\n                                    try {\n                                        if (fs.existsSync(rawPath)) {\n                                            extLogger.info(`[Automation] Coordinator detected at ${rawPath}. Restarting Zigbee2MQTT...`);\n                                            await wait(2000);\n                                            process.exit(1);\n                                        }\n                                    } catch (checkError) {\n                                        extLogger.error(`[Automation] Coordinator still unavailable: ${checkError.message}`);\n                                    }\n                                }\n                            })();\n                        }\n                    };\n                    port.onPortClose = portCloseCallback;\n                    activePort.once(\"close\", portCloseCallback);\n                } else {\n                    extLogger.info(\"[Automation] Failed to initialize Serial Port at extension startup\");\n                }\n            }\n            this.logger.info(\"[Automation] OTA Engine for Coordinator Initialized\");\n        } catch (error) {\n            this.logger.error(`[Automation] Failed to initialize OTA Engine for Coordinator: ${error.message}`);\n        }\n    }\n\n    stop() {\n        const controller = this.zigbee.zhController;\n        if (controller) {\n            controller.off(\"message\", this.onMessageBound);\n        }\n    }\n};\n```\n4. Save changes and **restart** Zigbee2MQTT.\n\n---\n\n## 🔋 Green Power Devices (Kinetic Switches)\n\nThis 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*).\nTo bind and declare these custom switches, add the following handlers:\n\n### Step 1: External Converter (Declaration)\n1. Open your Zigbee2MQTT Frontend Dashboard.\n2. Go to **Settings** ➡️ **Dev console** ➡️ **External Converters**.\n3. Create new converter:\n   * **Name**: `gp_moes_switch_ext.mjs`\n   * **Code**:\n```javascript\nimport {genericGreenPower} from \"zigbee-herdsman-converters/lib/modernExtend\";\nimport {presets, access} from \"zigbee-herdsman-converters/lib/exposes\";\nimport {hasAlreadyProcessedMessage} from \"zigbee-herdsman-converters/lib/utils\";\n\nconst GP_COMMANDS = {\n    16: \"toggle_1\",\n    17: \"toggle_2\",\n    18: \"toggle_3\",\n    24: \"toggle_1\",\n    25: \"toggle_2\",\n    26: \"toggle_3\",\n    32: \"toggle_1\",\n    33: \"toggle_2\",\n    34: \"toggle_3\",\n};\nconst baseExtend = genericGreenPower();\nbaseExtend.exposes = [\n    presets.action([\"toggle_1\", \"toggle_2\", \"toggle_3\"]),\n    presets.list(\"payload\", access.STATE, presets.numeric(\"payload\", access.STATE).withDescription(\"Byte\")).withDescription(\"Payload of the command\"),\n];\nbaseExtend.options = [\n    presets.text(\"target_toggle_1\", access.SET).withLabel(\"Object or Group\").withDescription(\"Friendly Name for Button 1 (toggle_1)\").withCategory(\"config\"),\n    presets.text(\"target_toggle_2\", access.SET).withLabel(\"Object or Group\").withDescription(\"Friendly Name for Button 2 (toggle_2)\").withCategory(\"config\"),\n    presets.text(\"target_toggle_3\", access.SET).withLabel(\"Object or Group\").withDescription(\"Friendly Name for Button 3 (toggle_3)\").withCategory(\"config\"),\n];\nbaseExtend.fromZigbee[0].convert = (model, msg, publish, options, meta) =\u003e {\n    const commandID = msg.data?.commandID;\n    if (!commandID || commandID \u003e= 0xe0 || hasAlreadyProcessedMessage(msg, model, msg.data.frameCounter, `${msg.device?.ieeeAddr}_${commandID}`)) return;\n    const gpdfCommandStr = GP_COMMANDS[commandID];\n    const payloadBuf = \"raw\" in msg.data.commandFrame ? msg.data.commandFrame.raw : undefined;\n    return {\n        action: gpdfCommandStr ?? `unknown_${commandID}`,\n        payload: payloadBuf?.length \u003e 0 ? Array.from(payloadBuf) : [],\n    };\n};\nexport default {\n    fingerprint: [{modelID: \"GreenPower_2\", ieeeAddr: /^0x00000000........$/}],\n    model: \"ZT-B-EU2\",\n    vendor: \"Moes\",\n    description: \"Green Power kinetic 1/2/3-gang wireless switch\",\n    extend: [baseExtend],\n};\n```\n4. Save changes.\n\n### Step 2: External Extension (Control Logic)\n1. Open your Zigbee2MQTT Frontend Dashboard.\n2. Go to **Settings** ➡️ **Dev console** ➡️ **External Extensions**.\n3. Create new extension:\n   * **Name**: `gp_switch_light_ext.mjs`\n   * **Code**:\n```javascript\nexport default class MoesSwitchLightExtension {\n    constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension, settings, logger) {\n        this.mqtt = mqtt;\n        this.eventBus = eventBus;\n        this.logger = logger;\n        this.allowedActions = new Set([\"toggle_1\", \"toggle_2\", \"toggle_3\"]);\n        this.baseTopic = settings.get()?.mqtt?.base_topic || \"zigbee2mqtt\";\n    }\n\n    start() {\n        this.eventBus.on(\"stateChange\", this.onStateChange.bind(this), this);\n        this.logger.info(\"[Automation] Moes GreenPower Multi-Switch Linker Initialized\");\n    }\n\n    async onStateChange(data) {\n        if (!data.update?.action || !this.allowedActions.has(data.update.action) || !data.entity?.options) {\n            return;\n        }\n        const target = data.entity.options[`target_${data.update.action}`];\n        if (target) {\n            this.mqtt.onMessage(`${this.baseTopic}/${target.trim()}/set`, JSON.stringify({ state: \"TOGGLE\" }));\n        }\n    }\n\n    stop() {\n        this.eventBus.removeListeners(this);\n    }\n};\n```\n4. Save changes and **restart** Zigbee2MQTT to load both scripts.\n\n---\n\n## 🤝 Contributing \u0026 Support\nIf you encounter bugs, missing chip parameters, or want to suggest improvements, feel free to open an [Issue](https://github.com/BioFieldUA/esp-coordinator/issues/new) or submit a Pull Request!\n\n---\n\n## 📄 License\nThis project is licensed under the **[PolyForm Strict License 1.0.0](./LICENSE.txt)**.\n* Non-commercial use, testing, and personal research are permitted.\n* Commercial production, integration into commercial platforms, or sales require a dedicated license.\n\nFor commercial inquiries, please contact: **biofield.com.ua@gmail.com**\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbiofieldua%2Fesp-coordinator","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbiofieldua%2Fesp-coordinator","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbiofieldua%2Fesp-coordinator/lists"}