Shelly Gen4 Matter Module — open-source Matter over Thread firmware for Shelly 1 Gen4, 1 Mini Gen4, 1PM Gen4 and 2PM Gen4
Replacement firmware that turns a Shelly 1 Gen4, Shelly 1 Mini Gen4, Shelly 1PM Gen4 or Shelly 2PM Gen4 (all ESP32-C6) into a native Matter over Thread device: no Shelly app, no cloud account, no WiFi required. Works with Home Assistant, Apple Home, Google Home and any other Matter 1.6 controller with a Thread border router.
One firmware image for all four models — pick the model on the built-in management page, no separate downloads.
Highlights
- Matter over Thread, BLE commissioning — behaves like an off-the-shelf Matter device: scan the code, done. Thread is the only network it needs.
- Install from the stock Shelly web UI — no UART, no opening the device. Return to the official Shelly firmware from the same page.
- Configurable Matter endpoints without reflashing — light switch (on/off, dim, colour temperature via Matter bindings), relay, temperature, occupancy, contact and electrical power measurement endpoints, up to 8 per module, chosen at runtime.
- Lua 5.4 scripting — per-endpoint button/relay/sensor logic, editable in the browser, stored on the module.
- Power metering — BL0942 (1PM) and dual-channel ADE7953 (2PM) exposed as Matter Electrical Power Measurement endpoints.
- Shelly Plus Add-on support — turn a relay module into a multi-sensor: DS18B20 temperature, a touch sensor (TTP223) or presence radar (HLK-LD2410S) on the digital input, and a 0–10 V analog input, each as its own Matter endpoint. See Shelly Plus Add-on.
- Matter bindings, unicast and group (multicast) — control lights directly from the wall switch, controller-independent.
- Management dashboard reachable over Thread (IPv6) — scripts, hardware, diagnostics, log, backup/restore. WiFi can be switched on for 10 minutes or permanently next to Thread when you prefer it.
- Updates — Matter OTA (e.g. from Home Assistant),
.binupload on the dashboard, or the Shelly-style.zippackage; A/B partitions with rollback. - Fabric recovery — removing the device from one controller reopens the commissioning window; removing the last one reboots into commissioning.
Version history: see CHANGELOG.md.
⚠️ Read this before you flash anything.Installing this firmware voids your Shelly warranty, and Shelly cannot provide technical support for a device running third-party code. It can remove the factory keys that enable Shelly Cloud and official OTA updates. Treat flashing as one-way unless you keep the full-chip backup you make before flashing. Incorrect flashing can brick your device, so always back up your original firmware before proceeding if reversibility is important to you. You assume all responsibility for any damage, data loss, or device failure.
This project is not affiliated with Shelly or Espressif Systems.
Each of the 8 script slots can be configured as one of these Matter endpoint types from the management dashboard, without reflashing or re-commissioning:
| Type | Matter device | Description |
|---|---|---|
| OnOff Toggle + Dim + Color | 0x0103 Light Switch (client) | Toggle, dim, color temp via bindings |
| OnOff State-follow | 0x0103 Light Switch (client) | On/Off follows switch position |
| Relay (OnOff Light) | 0x0100 OnOff Light (server) | Physical relay (GPIO from the active hardware profile; 2 relays on the 2PM) |
| Temperature Sensor | 0x0302 Temp. Sensor (server) | DS18B20 via 1-Wire, or any value set from Lua |
| Occupancy Sensor | 0x0107 Occupancy Sensor (server) | Analog IN duty cycle, or set from Lua |
| Illuminance Sensor | 0x0106 Light Sensor (server) | Lux value set from Lua |
| Contact Sensor | 0x0015 Contact Sensor (server) | Boolean state set from Lua |
| Electrical Power Measurement | 0x0510 Electrical Sensor (server) | Voltage/current/power/energy from the BL0942 (1PM) or ADE7953 (2PM), or set from Lua |
- esp-matter
release/v1.6(Matter 1.6) - connectedhomeip (as submodule within esp-matter)
- ESP-IDF v5.5.5
- Lua 5.4 (compiled as component)
One firmware image supports four Gen4 models (all ESP32-C6, 8 MB flash). Select the model on the management dashboard (Hardware → Device Type); the choice is stored in NVS and applied on the next boot. The correct GPIO mapping is then used for the relay(s), wall-switch input(s), onboard button and status LED.
| Model | Relay | Switch | Button | Status LED | Add-on (Digital IN) | Power meter | Board NTC |
|---|---|---|---|---|---|---|---|
| Shelly 1 Gen4 (default) | GPIO5 | GPIO10 | GPIO4 | GPIO15 | yes (GPIO18) | — | — |
| Shelly 1 Mini Gen4 | GPIO10 | GPIO12 | GPIO22 | GPIO5 | — | — | GPIO4 |
| Shelly 1PM Gen4 | GPIO4 | GPIO10 | GPIO1 | GPIO11 | yes (GPIO12) | BL0942 (UART1 GPIO7 + GPIO6, 9600 baud) | GPIO3 |
| Shelly 2PM Gen4 | GPIO5 + GPIO3 | GPIO11 + GPIO10 | GPIO12 | GPIO18 | yes (GPIO1) | ADE7953 dual-channel (IRQ=GPIO19, I2C SDA=GPIO6 SCL=GPIO7) | GPIO4 |
See STOCK_GPIO.md for the evidence per field. The Add-on Analog IN (GPIO17) and 1-Wire (GPIO16 in / GPIO9 out) are identical on every model; only Digital IN moves.
The polarity is the same on every model: relays are active-high, status LEDs active-low, the onboard button and Add-on Digital IN active-low (internal pull-up), and the wall-switch inputs active-high in normal operation (see BENCH_MODE for the development exception).
ℹ️ Shelly 1 Mini Gen4 is supported. Since installing straight from the stock Shelly web UI now works (see Firmware updates), the Mini can be flashed without opening it. The Mini has no Shelly Plus Add-on connector, so the add-on inputs are unavailable on it.
⚠️ Test status: only the Shelly 1 Gen4 and Shelly 1 Gen4 mini has been verified on real hardware. The other profiles match the stock firmware pin-for-pin but have not been hardware-tested. The BL0942 and ADE7953 scaling constants are placeholders and must be calibrated against a known load on real hardware before the reported voltage/current/power values are trustworthy.
⚠️ ADE7953 I2C pins are the one unverified value. Stock reads the I2C SDA/SCL pins from the device configuration in NVS instead of hardcoding them. Only the ADE7953 IRQ (GPIO19) is confirmed. The 2PM relay/switch pins themselves are confirmed against the stock firmware (relays on GPIO5/GPIO3, switches on GPIO11/GPIO10).
Notes:
- Changing the device type does not require Matter re-commissioning — the firmware exposes a generic switch model, so a type change is only a GPIO remap.
- Warning: selecting the wrong model drives the wrong GPIOs. Pick the model that matches your physical hardware.
- The Shelly Plus Add-on (DS18B20 + touch + analog occupancy) is available on the 1 Gen4, 1PM Gen4 and 2PM Gen4.
- On the 1PM Gen4 the BL0942 reports voltage, current, active power, accumulated energy and line frequency via a Matter Electrical Power Measurement endpoint, and on the dashboard Hardware tab.
- The 2PM Gen4 uses an ADE7953 measuring two channels (A = relay 1, B = relay 2). Each channel is exposed as its own Electrical Power Measurement endpoint. The two relays are two OnOff Light endpoints and both wall-switch inputs are reported to scripts (see the Lua section).
- The 1 Mini, 1PM and 2PM Gen4 have an onboard NTC (10 kΩ divider,
B ≈ 3350), read every 10 s and shown on the Hardware tab as Board temperature
(NTC) and in Lua as
input.board_temperature(). Above 95 °C on two consecutive readings while a relay is on, every relay switches off (overheat protection). The 1 Gen4 has no NTC; its Chip temperature is the ESP32-C6 die sensor.
| Component | Details |
|---|---|
| Shelly Plus Add-on | DS18B20 (TX=GPIO9/RX=GPIO16) + Digital IN (GPIO18 on the 1 Gen4) + Analog IN (GPIO17) |
| Thread Border Router | Google TV Streamer 4K (or any Thread BR) |
| Matter controller | Home Assistant Matter Server, Google Home, Apple Home |
| Commissioning | HA Matter Server UI or chip-tool |
The official Shelly Plus Add-on clips onto the 1 Gen4, 1PM Gen4 and 2PM Gen4 (not the Mini) and gives the module galvanically isolated low-voltage inputs behind the mains-side relay. With this firmware every Add-on input can be wired to its own Matter endpoint from a Lua script, so one module in a wall box becomes a relay plus a sensor — no extra Thread device, no extra power supply, and the sensor logic (e.g. "presence → light on, no presence for 5 min → light off") runs locally on the module, even when the controller is down.
| Add-on terminal | Typical sensor | Matter endpoint | Lua |
|---|---|---|---|
| Digital IN | TTP223 capacitive touch pad (a touch surface instead of a wall switch), reed/door contact, PIR or radar with a digital OUT such as the HLK-LD2410S | Contact Sensor, Occupancy Sensor, or a button that toggles/controls a bound light | input.digital(), contact_closed/contact_open and all button events with input.button_id() == 1 |
| Analog IN (0–10 V) | Presence sensors with an analog/PWM output, light-dependent resistors, 0–10 V process signals | Occupancy Sensor, Illuminance Sensor | input.analog() |
| DS18B20 (1-Wire) | Temperature probe, up to 1 sensor on the bus | Temperature Sensor | input.temperature() |
⚠️ Power budget: the Add-on's sensor supply delivers at most 10 mA. Only connect sensors that stay below that: a TTP223 (a few µA), a DS18B20 (≈1 mA while converting), a reed contact or the HLK-LD2410S are fine. The LD2410S is the special low-power variant and the one to buy for this — the regular HLK-LD2410/LD2410B/LD2410C draw far more than 10 mA and cannot be powered from the Add-on. Anything that needs more than 10 mA (mmWave radars other than the S variant, PIR boards with a relay output, modules with indicator LEDs) must not be connected to the Add-on supply at all.Digital IN has a built-in pull-up: the input idles high and reads true when the sensor pulls it to GND (< 0.5 V; > 2.5 V = idle). The sensor's output must therefore be able to sink the pin — open-drain/open-collector, a switch or reed contact to GND, or a push-pull output that swings between GND and its supply. An output that is idle-high and goes high on detection (TTP223 in its default active-high mode, the LD2410S OUT pin) works, but reads inverted: either select the active-low option of the sensor (TTP223
AHLBpad) or invert the value in your Lua script.
The wiring and polarity of each Add-on GPIO are listed under Pin mapping; example scripts for a touch pad, a presence sensor and a temperature probe are in SCRIPTS.md.
Keep a full backup before flashing (see the warning under Firmware updates).
There are two ways to install the firmware for the first time.
Option A — from the stock Shelly web UI (no UART, no opening the device):
-
Download the precompiled
shelly-gen4-matter-module-<model>-v<version>-ota.zipfor your model (1-gen4,1-mini-gen4,1pm-gen4or2pm-gen4) from the Releases page: https://github.com/hencou/shelly_gen4_matter_module/releases -
Or build the web-UI zip packages from source:
idf.py build
python3 tools/make-webui-ota-zip.py # → shelly-gen4-matter-module-<model>-v<version>-ota.zip, one per model
python3 tools/make-webui-ota-zip.py --model 1-mini-gen4 # only one modelThe firmware inside is the same for every model; only the manifest differs. The
stock updater installs an unsigned package only when its manifest name is the
module's own Shelly app code (S1G4, Mini1G4, S1PMG4, S2PMG4). A package
for another model fails with Signature verification of manifest for S1G4 failed. Verified on a Shelly 1 Gen4 running stock 2.0.1. After the install,
select the model under Hardware → Device Type on the management dashboard.
- Upload it through the stock Shelly device page: (Settings → Firmware, "install from file"):
Background info about the webui ota package:
The package ships no bootloader — only nvs, app and fs. It keeps the
stock "Shelly OS loader" so the stock updater's own A/B flow reliably boots our
app in the inactive slot. On the first boot the firmware then performs a
one-time self-migration: it writes our ESP-IDF bootloader to 0x0 plus
valid otadata and reboots. From then on the module boots via the standard
ESP-IDF bootloader + otadata, so OTA no longer depends on the stock loader's
proprietary SH0S boot-select or on future Shelly loader changes. This is the
only way to install on a Shelly 1 Mini Gen4, which has no accessible UART pads.
Option B — UART flash (see INSTALL.md): open the device and wire a USB-UART adapter to the J6 connector. This installs the ESP-IDF bootloader together with the partition table and app, and is also the way to make a full 8 MB backup of the stock Shelly firmware for a guaranteed return to stock.
After either first install, all further updates are over the air (Matter OTA
/ dashboard .bin upload, or a fresh web-UI package) — no UART needed.
Home Assistant prerequisite (once): this firmware uses the Matter test vendor ID (0xFFF1) and a test Device Attestation Certificate, so the Matter Server has to accept test-net devices. In Settings → Add-ons → Matter Server → Configuration turn on "Enable test-net DCL usage" and restart the add-on. Without it commissioning fails at the attestation step. The same switch is what later lets Home Assistant offer Matter OTA updates for this firmware, so enabling it now covers both.
After flashing (or a factory reset via the web interface) the module comes up in BLE commissioning mode straight away — no WiFi, no setup step first:
- Open Home Assistant companion App → Settings → Devices & Services → Matter → "Add device"
- Enter setup code: 34970112332 (default, configurable in
sdkconfig.defaults) - HA Matter Server pairs via BLE and provisions Thread credentials
- After ~30-60s the device appears in HA
A module that is already commissioned skips this and goes straight to Thread.
Removing the device from its last controller (e.g. deleting it in Home
Assistant) reboots it into this commissioning mode a few seconds later; WiFi
credentials and scripts are kept. If another fabric is still present (typical
when HA was commissioned from the Android app, which leaves a Google fabric
next to HA's — see Fabrics in /api/diag), the module instead opens its
commissioning window for 15 minutes over the network, so it can be re-added
with the same QR/pairing code through the Thread border router without
touching the other fabrics.
Endpoints do not have to exist before commissioning. Open the management dashboard (over Thread, or via the 10-minute WiFi window — see below: Reach the management dashboard over Thread) and go to the Scripts tab:
- Set a name, endpoint type, trigger, and Lua script for each slot you need
- Click Save for each slot
- Click Reboot on the Scripts page
The new endpoints appear in Home Assistant by themselves; if not, re-interview the device (Matter integration → device → Reconfigure/interview).
See SCRIPTS.md for example scripts.
After commissioning, Thread + Bluetooth are active for Matter communication. WiFi is off by default.
The dashboard is served over IPv6 on the Thread network, so you can reach it without WiFi: Easiest way to find the IPv6 addres of the module is in Home Assistant → Devices → Matter → Click on device → Click on Matter info. Use this IPv6 address in the browser between square brackets, example: http://[ipv6 adress of module]
Other ways to find the IPv6 address:
- Direct (always works): the module logs its addresses at boot. Use the OMR / SLAAC address (marked
<-- OMRin the log, e.g.fd96:…) and openhttp://[<omr-ipv6>]/from any host that routes to the Thread network through your border router. The mesh-local (fd…:0:0:ff:fe00:…) and link-local (fe80:…) addresses are not routable off-mesh. - By name (mDNS): the module advertises an
_http._tcpservice (instance label = configured hostname) via the Matter SRP client. A border router with an advertising proxy re-publishes it as LAN mDNS, so you can discover it withavahi-browse -rt _http._tcp/dns-sd -B _http._tcp, or generate a clickable overview of all modules withtools/shelly-overview.sh. Note the resolvable.localname is the opaque CHIP SRP host (e.g.52E2….local), not the friendly hostname — the friendly hostname is the service label you browse.
Direct device-to-device control (a switch bound to a lamp) needs the switch to resolve the lamp's operational address over Thread DNS-SD. That resolution is answered by the Thread network's SRP / DNS-SD server. Some border routers register services and proxy them to LAN mDNS, but do not answer the operational-discovery query a Thread node makes for another node — so a freshly reset/commissioned switch times out while resolving the lamp, even though the lamp is reachable and works from Home Assistant.
The fallback server (Hardware tab → SRP fallback server, srp_mode) runs an
on-device SRP / DNS-SD server, but only while the mesh has no border router; it
yields the moment a real one appears, and only one module takes the role
(election on RLOC16, lowest wins). The server needs the Router or Leader role, so
a module that is only an End Device never runs it.
How it is built: CONFIG_OPENTHREAD_BORDER_ROUTER stays off — turning it on
links the prebuilt libopenthread_br.a plus the ESP border-router glue, whose
MeshCoP mDNS publisher needs an infra/backbone interface this device does not
have, and the module then hangs right after Thread attach on every reboot after
commissioning. The SRP/DNS-SD server sources (srp_server.cpp,
dnssd_server.cpp) are part of every FTD build though — only the enabling
macros sit inside ESP-IDF's #if CONFIG_OPENTHREAD_BORDER_ROUTER block. So the
server is enabled through a custom OpenThread header
(main/openthread_custom_config.h), which
needs no border-router glue at all.
The fallback has no advertising proxy: it fixes discovery inside the mesh (a switch resolving a bound lamp), not discovery of the mesh from your LAN. For that, add a real Thread border router with an advertising proxy (e.g. a Home Assistant OpenThread Border Router on a ZBT-1/SkyConnect dongle).
The WiFi buttons on the dashboard (WiFi & OTA tab) join WiFi as a station next to a running Thread network, without a reboot:
- 10 min switches WiFi off again after 10 minutes; pressing it again extends the window.
- Always on keeps WiFi up permanently and restores it after every reboot (stored in flash). Unlike the 10-minute window it never pauses the Lua scripts, so it needs enough free heap for the WiFi driver next to the configured slots — with many slots use the 10-minute window instead. Only available once the device is commissioned (see below).
- Off closes the window or ends "always on".
The physical shortcut does exactly the same: press any button 6× rapidly (within 2.5 seconds). Use that when the dashboard is unreachable over Thread. 6× on the PCB button additionally hands GPIO16/17 back to the UART0 console right away (no reboot), so the serial log on J6 shows the WiFi window even when bench mode is off. The Add-on sensors (DS18B20, Analog IN) pause until the next reboot; the SW polarity is not changed. Only do this with the Add-on removed.
Apply on that tab only stores SSID, password, hostname and firmware URL — no reboot. The next window uses them, so a wrong SSID costs a toggle instead of a restart. Restart next to it reboots the device on request.
The 10-minute window works regardless of commissioning status. Without saved
WiFi credentials — or when they do not connect within a minute — the window
switches to an open SoftAP shelly-cfg-XXXXXX with the dashboard on
http://192.168.4.1/ for the remainder of the ten minutes. That is how you
reach a module that has never been commissioned, and it keeps a module with
stale credentials reachable when Thread is not configured either.
A module that is not in a Matter fabric yet (fresh install, after a factory reset or Commission Mode) has no Thread network but advertises continuously over BLE for pairing. BLE advertising outranks WiFi in the radio arbiter, so with both on the module associates and even gets an IP address but then hardly anything gets through: no ping, no dashboard, and a laptop on the SoftAP never gets a DHCP lease (it falls back to a 169.254.x.x address). BLE also holds so much heap that next to Matter and the WiFi driver only ~3 kB is left, too little for the dashboard's TCP sends. The firmware therefore shuts BLE down and releases its memory when the WiFi window opens, and reboots when the window closes (after 10 minutes, or via Off): the fresh boot advertises over BLE for commissioning again. A reboot from the dashboard also ends the window, since an uncommissioned module boots without WiFi; press 6× again to reopen it. That is why Always on is refused until the device is commissioned — it would keep BLE, and with it commissioning, off for good — and why a stored "Always on" is downgraded to a 10-minute window at boot as long as the device is uncommissioned (the setting is kept and takes effect again after commissioning). Commission the device first, then switch WiFi to Always on.
With no Thread network to share the radio with, the window before commissioning also keeps the WiFi station awake (no modem sleep) and takes a Thread interface that is still up without a fabric down until the window closes.
WiFi and 802.15.4 share one radio on the ESP32-C6, and Espressif documents only one stable combination: WiFi station next to a Thread End Device (SoftAP next to a Thread Router is listed as unsupported, and SoftAP next to an End Device only as limited once a client is connected). The 10-minute window therefore:
- starts WiFi as STA (SoftAP when there are no credentials, or when they fail);
- keeps Thread up, but gives up the router role: no routing for other nodes, no children;
- in the SoftAP fallback additionally makes Thread a sleepy child, 3 s parent poll). A station has an AP buffering frames for it while the radio serves 802.15.4, a SoftAP has nothing of the kind: with 802.15.4 receiving all the time, clients associate but never get a DHCP lease. Thread stays attached, but mesh traffic is as slow as the poll period until the window closes. Sleepy mode also drops the FTD role and full network data in the same link mode, because OpenThread refuses rx-off-when-idle on a full Thread device. If the stack still refuses, Thread goes down for the rest of the window and comes back at teardown: an unreachable module is worse than a Thread outage that ends by itself;
- re-arms the coexistence arbiter before every
esp_wifi_start(), because stopping WiFi hands the radio back to 802.15.4 — the SoftAP fallback would otherwise run without airtime; - stands down the SRP fallback server, which requires Router/Leader;
- shares the radio, so expect more Thread packet loss while WiFi is busy.
All of that is restored automatically when the window closes — no reboot. If Thread detaches while the window is open, the Thread watchdog closes the window immediately: Thread wins over temporary WiFi — unless the SoftAP fallback took Thread down deliberately, in which case the detached state is expected. The current state is visible on the Hardware tab (Temporary WiFi).
Via the management dashboard → Backup tab:
- Download Backup — exports all settings as a JSON file: WiFi credentials + all 8 script slot configurations (name, type, trigger, period, Lua code)
- Restore Backup — upload a previously downloaded JSON backup to restore all settings. The device reboots automatically after restore.
Via the web management dashboard → Factory Reset button, or by holding the onboard PCB button for 30 seconds (only that button — the wall-switch and Add-on inputs never trigger a reset; the status LED starts blinking fast when the reset is accepted). Both wipe:
- All NVS data (WiFi credentials, script configurations, bench mode)
- All Matter fabrics and commissioning data (NVS namespaces)
After factory reset the module reboots into BLE commissioning mode (step 2).
Once the custom firmware is running you can update it three ways. They all flash the same application binary (build/shelly_gen4_matter_module.bin) — they only differ in transport, and all use standard ESP-IDF OTA slot selection with bootloader rollback (a bad image is automatically reverted to the previous slot).
⚠️ Keep a full UART backup as your guaranteed way back. The management page can flash an original Shelly firmware package back onto the device (see Return to stock below), and that path is verified on a Shelly 1 Gen4 — but it depends on the package matching your device. The one route that always works is restoring the full 8 MB UART backup of that exact device, so make that backup before the first flash (with ESPConnect oresptool.py read_flash, see INSTALL.md) — afterwards it is too late. The factoryshellypartition (hardware/Matter credentials) is never overwritten and is preserved across installs.
Update over the existing Thread/Matter connection — no WiFi or cabling needed.
-
Download a precompiled image
shelly-gen4-matter-module-v<version>-otafrom here: https://github.com/hencou/shelly_gen4_matter_module/releases -
Or build the
.otaimage:
idf.py build
python3 tools/make-matter-ota.py # → shelly-gen4-matter-module-v<version>.ota- And serve it from a Matter OTA provider (e.g. Home Assistant; requires "Enable test-net DCL usage" in the Matter Server add-on, see commissioning):
The image embeds the vendor/product ID and software version; the device only accepts an image with a higher software version than it currently runs.
For modules already running this firmware: open the management dashboard (6× rapid button press → WiFi), go to the WiFi & OTA tab, and either provide a firmware URL or upload build/shelly_gen4_matter_module.bin directly. The device flashes the inactive OTA slot and reboots into it; on boot the app marks itself valid, otherwise the bootloader rolls back.
The management dashboard (Backup tab) can flash an original Shelly firmware package back onto the module — the same .zip the stock web UI consumes. Download the package matching the model on your device label, then upload it under Return to stock Shelly firmware.
The stock app cannot run under our ESP-IDF bootloader (it needs the Shelly OS loader and an SH0S boot state), so the module is made byte-for-byte stock again. The firmware first writes the stock app to the inactive slot and the stock filesystem, and verifies their SHA-256 — nothing outside that inactive slot is touched until this succeeds. It then restores, in order, the stock boot state (otadata), the stock partition table (0x10000), points the SH0S boot-select at the slot the stock app landed in, and finally rewrites the stock bootloader (Shelly OS loader) at 0x0. Every write is verified by read-back. The factory shelly partition is never touched. The units are not flash-encrypted, so the plaintext images from the package reproduce the stock layout exactly.
The restore also erases our nvs partition, so the device comes back up as a
factory-fresh stock unit and has to be set up again from scratch.
✅ Verified on a Shelly 1 Gen4: the stock
S4SW-001X16EUpackage restored over the air boots the Shelly OS loader and the stock app again.
⚠️ The bootloader rewrite at0x0is the one irreversible step. If it is interrupted (power loss mid-write) the device has no valid loader and needs UART recovery. Keep your full 8 MB UART backup as the guaranteed fallback.
Onboard Shelly 1 Gen4:
| GPIO | Function |
|---|---|
| GPIO4 | PCB button — active-low, internal pull-up |
| GPIO5 | Relay output — active-high (high = relay closed) |
| GPIO10 | Pushbutton input / SW terminal — active-high, no internal pull (mains-referenced input circuit). In bench mode it becomes active-low with a pull-up so a plain button to GND works, see BENCH_MODE |
| GPIO15 | Status LED — active-low (low = LED on) |
Shelly Plus Add-on (via J6 connector):
| GPIO | Function |
|---|---|
| GPIO9 | 1-Wire TX — DS18B20 commands via ISO7221A isolator. Active-low open-drain signalling (idle high); the isolator does not invert, so driving GPIO9 low pulls the bus low |
| GPIO16 | 1-Wire RX — DS18B20 responses via isolator. Active-low, idle high; a presence pulse reads as low |
| GPIO17 | Analog IN — occupancy sensor (e.g. HLK-LD2410S). Active-high PWM duty cycle with internal pull-down; ≥25 % duty (≈2.5 V on the 0–10 V scale) counts as occupied |
| GPIO18 | Digital IN — TTP223 capacitive touch / add-on switch. Active-low, internal pull-up (touch/contact active pulls the pin low). The Add-on terminal itself is active-low too (Shelly specifies −15 V…0.5 V = true, 2.5 V…15 V = false), so the isolator passes the level through uninverted. The stock firmware's "invert digital input" setting does not exist here — invert it in your Lua script if you need the opposite sense |
J6 connector pinout (1.27 mm pitch, 7-pin header on back of PCB):
| Pin | Function | GPIO | Notes |
|---|---|---|---|
| 1 | ESP_DBG_UART | GPIO18 | not used for flashing |
| 2 | TXD | GPIO16 | Shelly TXD → CP2102 RXD |
| 3 | RXD | GPIO17 | Shelly RXD ← CP2102 TXD |
| 4 | 3.3V | — | power supply (no 5V!) |
| 5 | RESET | EN | not needed for manual flashing |
| 6 | GPIO0 (BOOT) | GPIO0 | low at power-up → flash mode |
| 7 | GND | — | pin closest to J6 silkscreen |
| Function | Returns | Description |
|---|---|---|
input.button_event() |
string or nil | Last button event (see events table below) |
input.button_id() |
integer | Input that triggered the event: 0=SW, 1=Digital IN, 2=PCB button, 3=SW2 (2PM) |
input.sw() |
boolean | Current state of SW input (GPIO10) |
input.digital() |
boolean | Current state of Digital IN (GPIO18 on the 1 Gen4) |
input.device_btn() |
boolean | Current state of PCB button (GPIO4) |
input.analog() |
integer | Analog IN duty cycle 0–100 % (GPIO17) |
input.temperature() |
number | DS18B20 (Add-on) temperature in °C |
input.chip_temperature() |
number or nil | ESP32-C6 internal temperature in °C (all models) |
input.board_temperature() |
number or nil | Onboard NTC temperature in °C (1 Mini, 1PM, 2PM; nil on the 1 Gen4) |
| Event string | Description |
|---|---|
"short_press" |
Short press (< 500ms) |
"long_press_start" |
Long press started |
"long_press_stop" |
Long press released |
"double_press" |
Double press |
"short_long_start" |
Short press followed by long press started |
"short_long_stop" |
Short-long press released |
"contact_closed" |
Button/switch contact closed (pressed) |
"contact_open" |
Button/switch contact opened (released) |
The SW / button / Digital-IN GPIOs depend on the selected model (see the
hardware table above). The GPIO column below lists the default Shelly 1 Gen4
pins; the 2PM-specific pins are noted separately.
| ID | Input | GPIO |
|---|---|---|
0 |
SW (1st wall switch) | GPIO10 (1 Gen4) — GPIO11 on 2PM |
1 |
Digital IN (add-on) | GPIO18 (1 Gen4) — GPIO12 on 1PM, GPIO1 on 2PM |
2 |
PCB button (onboard) | GPIO4 |
3 |
SW2 (2nd wall switch, 2PM only) | GPIO10 on 2PM |
The relay functions take an optional 1-based channel argument (1 = relay 1,
2 = relay 2 on the 2PM). When omitted, channel 1 is used.
| Function | Description |
|---|---|
output.relay_set(on) |
Set relay 1 on/off (on = boolean) |
output.relay_set(ch, on) |
Set relay ch (1 or 2) on/off |
output.relay(...) |
Alias for output.relay_set |
output.relay_toggle([ch]) |
Toggle relay ch (default 1) |
output.relay_state([ch]) |
Returns state of relay ch (default 1) as boolean |
Script migration note: the relay API is now channel-indexed. Existing single-relay scripts using
output.relay_set(true)/output.relay_toggle()/output.relay_state()keep working unchanged (they act on relay 1). Only 2PM scripts that need the second relay must pass a channel:output.relay_set(2, true).
| Function | Description |
|---|---|
endpoint.command("toggle") |
Send OnOff Toggle to bound devices |
endpoint.command("on") |
Send OnOff On |
endpoint.command("off") |
Send OnOff Off |
endpoint.command("move_with_onoff", {up=bool, rate=N}) |
Start dimming (up=true sends On first; up=false on an off lamp does nothing) |
endpoint.command("stop") |
Stop dimming |
endpoint.command("color_temp_set", {mireds=N}) |
Set color temperature |
endpoint.command("color_temp_move", {warmer=bool, rate=N}) |
Start color temp change |
endpoint.command("color_temp_stop") |
Stop color temp change |
| Function | Description |
|---|---|
endpoint.set(attr, value) |
Set sensor attribute (for server endpoints) |
log(msg) |
Print to serial log |
timer.millis() |
Uptime in milliseconds |
| State | WiFi | Thread/BLE | How to reach |
|---|---|---|---|
| Not commissioned | OFF | ON (BLE commissioning) | After flash or factory reset |
| Commissioned (normal) | OFF | ON (Thread active) | Dashboard over Thread |
| WiFi next to Thread (management) | ON — STA, SoftAP when STA fails | Thread active as sleepy End Device (no router role, no SRP fallback; Thread down if sleepy is refused) | "10 min" / "Always on" buttons or 6× press — no reboot; 10-min window restores itself, "Always on" survives reboots (commissioned devices only) |
| WiFi window, not commissioned | ON — STA, SoftAP when STA fails | No Thread network; BLE shut down, reboot into BLE commissioning when the window closes | "10 min" button or 6× press only — "Always on" is refused until commissioned |
The onboard status LED (GPIO15) indicates the device state:
| Pattern | Description |
|---|---|
| Fast blink (5 Hz) | Boot / initialization in progress, or OTA update active |
| Slow blink (1 Hz) | Not commissioned — waiting for BLE pairing |
| Heartbeat (short flash every 2s) | Normal operation — commissioned and online |
| Off | LED disabled or no pattern set |
During boot the LED blinks fast. After initialization it switches to heartbeat (if commissioned) or slow blink (if not yet commissioned).
A unicast binding sends over a cached CASE session, and that session holds the IPv6 address the peer had when the session was created. Thread addresses are not stable — a border router restart hands out a new on-mesh prefix, and a parent change gives a node a new RLOC — so the address can go stale while the session still looks healthy. Sending then goes nowhere and MRP retries for ~35 s.
Three mechanisms keep that off the critical path:
| Mechanism | What it does |
|---|---|
| Invoke response timeout (1.5 s) | After a timeout the session is evicted and the command is retried once over a fresh CASE session — which does a new DNS-SD lookup. Skipped when the peer acknowledged the first attempt, because Toggle is not idempotent. |
| Thread network-data watch | Automatic. When the set of on-mesh prefixes changes, every cached session to a bound peer is evicted right away, because all peer addresses just expired. |
| Binding keepalive | Configurable. Reads ClusterRevision from each bound peer's bound cluster every N seconds and drops the session when that read fails, so the failure is discovered in the background instead of on a button press. |
The keepalive interval is set on the Hardware tab or over HTTP; 0 disables it,
the minimum is 60 s. Default is 600 s.
Controls GPIO10 polarity and sensor initialization. Configurable at runtime via the management dashboard.
| BENCH_MODE | GPIO10 | Sensors | Use case |
|---|---|---|---|
| 0 | Active-high (230V optocoupler) | Active | Production |
| 1 (default) | Active-low + pull-up | Skipped (UART0 stays active) | Development |
To get the serial log without switching bench mode (for example when the dashboard cannot be reached), press the PCB button 6×: UART0 comes back immediately and stays until the next reboot, see Switch to WiFi for faster management.
- INSTALL.md — Linux/macOS/WSL2 command-line setup with esp-matter and ESP-IDF
- INSTALL_VSCODE_WINDOWS.md — VS Code on Windows 11 + WSL2
shelly_gen4_matter_module/
├── CMakeLists.txt
├── sdkconfig.defaults
├── main/
│ ├── app_config.h # pins, timings, BENCH_MODE
│ ├── app_main.cpp # boot sequence, smart boot logic
│ ├── matter_device.cpp # dynamic endpoint creation + command emit
│ ├── script_engine.c # Lua 5.4 scripting engine
│ ├── hw_config.c/.h # runtime hardware profile (1/Mini/1PM/2PM)
│ ├── button.c/.h # button driver (wall switches, PCB button, gestures)
│ ├── relay.c/.h # relay GPIO control (1 or 2 channels)
│ ├── power_meter.c/.h # BL0942 driver (1PM Gen4)
│ ├── ade7953.c/.h # ADE7953 dual-channel driver (2PM Gen4)
│ ├── sensors.c/.h # DS18B20 + analog occupancy
│ ├── ota.c/.h # WiFi runtime, management dashboard, OTA
│ ├── status_led.c/.h # LED patterns
│ ├── secrets.h # compile-time WiFi credentials (gitignored)
│ └── CHIPProjectConfig.h # vendor/product name overrides
├── components/lua/ # Lua 5.4 as ESP-IDF component
├── tools/
│ ├── make-matter-ota.py # build Matter OTA image (.ota) — see Firmware updates
│ ├── make-webui-ota-zip.py # build Shelly Stock web-UI OTA zip
│ ├── make_factory_bin_file.sh # merge binaries for UART/ESPConnect flashing
│ ├── create_matter_cluster_group.py # set up multicast group + bindings
│ └── shelly-overview.sh # clickable HTML overview of modules via mDNS
├── SCRIPTS.md # example Lua scripts
├── INSTALL.md
└── INSTALL_VSCODE_WINDOWS.md
- Test vendor ID: firmware uses vendor ID 0xFFF1. For Google/Apple Home publication a CSA vendor ID is required.
- Test DAC: for production, provision real Device Attestation Certificates in the NVS
chip-factorynamespace. For local HA usage the test DAC works fine once "Enable test-net DCL usage" is on in the Matter Server add-on.
Espressif esp-matter and connectedhomeip: Apache 2.0. Lua: MIT. This custom code: MIT.