From 057d7dfa47f10abfa43969d152196c56feae7a34 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 23 May 2026 14:12:30 +0100 Subject: [PATCH 1/7] docs(dali_gear): update readme to match implemented behaviour - Document non-standard combined DAPC+CCT master flow - Document QUERY COLOUR TYPE (0xE7) backward frame response (0x02) - Remove stale 'No backward frame responses' limitation - Fix config key names (pin_rx/pin_tx, correct defaults 14/17) - Add Waveshare Pico-DALI2 as example hardware - Add note about enabling White Balance Correction for RGB-only strips - Add lib_deps to platformio_override.ini example snippet --- usermods/dali_gear/library.json | 7 + usermods/dali_gear/readme.md | 140 ++++++++ usermods/dali_gear/usermod_dali_gear.cpp | 437 +++++++++++++++++++++++ wled00/const.h | 1 + wled00/pin_manager.h | 3 +- 5 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 usermods/dali_gear/library.json create mode 100644 usermods/dali_gear/readme.md create mode 100644 usermods/dali_gear/usermod_dali_gear.cpp diff --git a/usermods/dali_gear/library.json b/usermods/dali_gear/library.json new file mode 100644 index 0000000000..bfe6562fb6 --- /dev/null +++ b/usermods/dali_gear/library.json @@ -0,0 +1,7 @@ +{ + "name": "dali_gear", + "build": { "libArchive": false }, + "dependencies": { + "qqqDALI": "https://github.com/netmindz/DALI-Lighting-Interface.git#fix/esp32-volatile-cast" + } +} diff --git a/usermods/dali_gear/readme.md b/usermods/dali_gear/readme.md new file mode 100644 index 0000000000..d300d8f49a --- /dev/null +++ b/usermods/dali_gear/readme.md @@ -0,0 +1,140 @@ +# DALI Gear Usermod + +Makes WLED act as a **DALI control gear** (IEC 62386) — i.e. a light that responds to commands from an external DALI master (wall dimmer, BMS, building automation system, etc.). + +DALI (Digital Addressable Lighting Interface) is a standardised two-wire bus protocol for lighting control. This usermod puts WLED on the bus as a gear device: the DALI master sends brightness/on/off/colour commands, and WLED adjusts its LEDs accordingly. + +> **ESP32 only.** The hardware timer API used for Manchester decoding is not available on ESP8266. + +## Hardware + +You need a DALI bus interface circuit to convert between the DALI bus voltage (9.5–22.5 V) and the ESP32's 3.3 V GPIO levels. + +### Minimal DIY circuit (from [qqqlab/DALI-Lighting-Interface](https://github.com/qqqlab/DALI-Lighting-Interface)) + +``` +3.3V ESP32 5.6V ___ + Zener +----|___|---- 12V Power Supply + ___ Diode | 220 Ω +RX ---+-----|___|---|>|----------+------------- DALI+ + | 10K | + +-+ | + | | 100K ___ |/ PNP DALI BUS + +-+ TX ---|___|----| Transistor + | 1K |\ + | V +GND --+---------------------------+------------- DALI- +``` + +> **Note:** For this circuit the TX polarity must be inverted. The transistor pulls the bus low when the GPIO is HIGH. Configure your TX pin accordingly and invert the output in hardware or adjust the HAL callbacks in the source. + +Commercial DALI interface modules (e.g. Waveshare Pico-DALI2, Mikroe DALI Click) are a simpler alternative. + +### Pin assignment + +| Signal | Direction | Description | +|---|---|---| +| RX | Input | Reads DALI bus state (high = bus idle, low = bus asserted) | +| TX | Output | Drives DALI bus — needed for backward frame responses to QUERY commands | + +Default pins are **RX=14, TX=17** (Waveshare Pico-DALI2). + +Configure both pins in the WLED usermod settings page. + +## Configuration + +| Setting | Default | Description | +|---|---|---| +| Enabled | false | Enable/disable the usermod | +| pin_rx | 14 | GPIO for DALI bus RX | +| pin_tx | 17 | GPIO for DALI bus TX | +| daliAddr | -1 | Short address (0–63) to respond to, or -1 to respond to broadcast only | + +## DALI commands handled + +### Direct Arc Power Control (DAPC) + +When the master sends a DAPC frame, the arc level (0–254) is mapped linearly to WLED brightness (0–255). WLED's existing gamma correction handles perceptual uniformity at the LED output. + +| DALI arc level | WLED behaviour | +|---|---| +| 0 | Turn off | +| 1–254 | Set brightness proportionally, turn on | +| 255 (mask) | Ignored (no change) | + +### Indirect commands + +| Command | Number | WLED action | +|---|---|---| +| OFF | 0 | Turn off | +| UP | 1 | Increase brightness by 10 | +| DOWN | 2 | Decrease brightness by 10 | +| STEP UP | 3 | Increase brightness by 1 | +| STEP DOWN | 4 | Decrease brightness by 1 | +| RECALL MAX LEVEL | 5 | Set brightness to 255, turn on | +| RECALL MIN LEVEL | 6 | Set brightness to 1, turn on | +| STEP DOWN AND OFF | 7 | Decrease by 1; turn off if at minimum | +| ON AND STEP UP | 8 | Turn on if off, then increase by 10 | +| GO TO LAST ACTIVE LEVEL | 10 | Restore last brightness before turn-off | + +### DT8 colour temperature (IEC 62386-209) + +Colour temperature commands from a DALI master are mapped to WLED's CCT value via `strip.setCCT()`. The mired value is converted to Kelvin (`K = 1,000,000 / mireds`). WLED's accepted range is 1900–10091 K; values outside this range are clamped. + +Two CCT application flows are supported: + +**Standard flow (IEC 62386-209 §11.3.4.1):** + +1. `SET DTR0` — lower byte of colour temperature in mireds +2. `SET DTR1` — upper byte of colour temperature in mireds +3. `ENABLE DEVICE TYPE 8` — activates DT8 interpretation +4. `SET TEMPORARY COLOUR TEMPERATURE` (0xE1) — loads DTR0+DTR1 into temporary register +5. `ACTIVATE` (0xE2) — applies the temporary colour temperature + +**Non-standard combined flow (observed in some masters):** + +Some DALI masters skip the `0xE1` + `0xE2` sequence and instead apply the colour temperature implicitly alongside the subsequent DAPC command. The usermod detects this: if DTR0/DTR1 are set and DT8 is active when a DAPC frame arrives, the CCT is applied at the same time as the brightness change. + +**QUERY COLOUR TYPE (0xE7):** + +Some masters query the gear's colour capabilities before sending CCT commands. The usermod responds with a backward frame value of `0x02` (bit 1 = Tc colour temperature supported), sent 4 ms after the query frame to meet the DALI spec requirement (7Te–22Te ≈ 2.9–9.2 ms settling window). + +`SET DTR0`, `SET DTR1`, and `ENABLE DEVICE TYPE 8` are sniffed as broadcast-level frames regardless of the configured `daliAddr`. + +### CCT on RGB-only strips + +`strip.setCCT()` adjusts the colour temperature via WLED's internal CCT pipeline. For an RGB-only strip (no dedicated white or CCT channel), **White Balance Correction** must be enabled in WLED LED settings (Config → LED Preferences → White Balance Correction) for the CCT value to affect the LED output. This makes WLED apply a colour temperature correction on the RGB channels. + +## Addressing + +DALI addressing works as follows: + +- **Broadcast** (`0xFE`/`0xFF`): always handled regardless of `daliAddr` setting +- **Short address** (0–63): set `daliAddr` to the address the DALI master has assigned to this device +- **Group address**: not handled + +Set `daliAddr` to `-1` (default) to respond only to broadcast commands. This is useful for a single-gear installation. + +## Enabling the usermod + +Add to your `platformio_override.ini`: + +```ini +[env:esp32dev] +custom_usermods = dali_gear +lib_deps = + ${env.lib_deps} + https://github.com/netmindz/DALI-Lighting-Interface.git#fix/esp32-volatile-cast +``` + +## Limitations + +- No short address commissioning via DALI bus (set the address manually in WLED config) +- No group address support +- No DALI scene mapping + +## Dependencies + +- [qqqlab/DALI-Lighting-Interface](https://github.com/qqqlab/DALI-Lighting-Interface) (GPL-3.0) + Low-level Manchester-encoded DALI bus driver by qqqlab. + This usermod uses the fork at `https://github.com/netmindz/DALI-Lighting-Interface.git#fix/esp32-volatile-cast` which includes an ESP32 volatile-cast fix. diff --git a/usermods/dali_gear/usermod_dali_gear.cpp b/usermods/dali_gear/usermod_dali_gear.cpp new file mode 100644 index 0000000000..f734395409 --- /dev/null +++ b/usermods/dali_gear/usermod_dali_gear.cpp @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DALI Gear Usermod for WLED +// Makes WLED act as a DALI control gear (IEC 62386) — i.e. a light that +// responds to commands from an external DALI master (wall dimmer, BMS, etc.). +// +// Phase 1: RX-only bus listening. Handles DAPC (direct arc power control) +// and basic indirect commands (OFF, MAX, MIN, UP, DOWN, LAST ACTIVE LEVEL). +// Phase 2: DT8 colour temperature (IEC 62386-209). Handles SET DTR0/DTR1 +// special commands, ENABLE DEVICE TYPE 8, SET TEMPORARY COLOUR TEMPERATURE, +// ACTIVATE to map DALI Tc (mireds) → WLED CCT (Kelvin), and backward frame +// response to QUERY COLOUR TYPE (0xe7) to declare Tc support to the master. +// +// Hardware: requires a DALI bus interface circuit (see readme.md). +// ESP32 only — uses hardware timer API not available on ESP8266. + +#include "wled.h" + +#ifndef ARDUINO_ARCH_ESP32 +#error "dali_gear usermod requires ESP32 (hardware timer API not available on ESP8266)" +#endif + +#include + +// --------------------------------------------------------------------------- +// DALI frame parsing helpers +// --------------------------------------------------------------------------- + +// Returns true if the address byte of a forward frame is addressed to us. +// daliAddr: our configured short address (0–63), or -1 to accept broadcast only. +static bool daliAddressedToMe(uint8_t addrByte, int8_t daliAddr) { + // Broadcast: 1111 111x (0xFE or 0xFF) + if ((addrByte | 0x01) == 0xFF) return true; + // Short address: 0AAA AAA x — top bit 0 + if (!(addrByte & 0x80) && daliAddr >= 0) { + uint8_t frameAddr = (addrByte >> 1) & 0x3F; + return frameAddr == (uint8_t)daliAddr; + } + // Group address: 100A AAA x — not handled in phase 1 + return false; +} + +// Map a DALI arc level (1–254) to WLED bri (1–255). +// Linear mapping is correct here: WLED's gamma correction handles the LED +// output curve, serving the same perceptual-uniformity purpose as DALI's +// logarithmic arc power table. +static uint8_t daliLevelToWledBri(uint8_t level) { + if (level == 0) return 0; + // level 1–254 → bri 1–255 + return (uint8_t)(((uint16_t)level * 255u + 127u) / 254u); +} + +// --------------------------------------------------------------------------- +// ISR and timer — file-scope so the ISR can reach the Dali instance +// --------------------------------------------------------------------------- + +static Dali _dali; +static hw_timer_t *_daliTimer = nullptr; + +static void ARDUINO_ISR_ATTR daliTimerISR() { + _dali.timer(); +} + +// --------------------------------------------------------------------------- +// Usermod class +// --------------------------------------------------------------------------- + +class DaliGearUsermod : public Usermod { + private: + bool _enabled = false; + bool _initDone = false; + int8_t _rxPin = 14; // default: Waveshare Pico-DALI2 RX + int8_t _txPin = 17; // default: Waveshare Pico-DALI2 TX + int8_t _daliAddr = -1; // -1 = respond to broadcast only + uint8_t _lastDaliLevel = 0; // last DALI arc level received (for info panel) + + // DT8 (IEC 62386-209) colour temperature state + uint8_t _dtr0 = 0; // Data Transfer Register 0 (low byte of Tc mireds) + uint8_t _dtr1 = 0; // Data Transfer Register 1 (high byte of Tc mireds) + bool _dt8Active = false; // true after ENABLE DEVICE TYPE 8 + uint16_t _tempCCT = 0; // temporary colour temperature register (mireds) + uint16_t _lastCCTKelvin = 0; // last applied CCT in Kelvin (for info panel) + + // Backward frame scheduling — DALI requires response 7Te–22Te (≈2.9–9.2ms) + // after the forward frame stop bits. We schedule via timestamp. + uint8_t _pendingBF = 0; // backward frame byte to send (0 = none pending) + uint32_t _pendingBFTime = 0; // millis() threshold — send when now >= this + + static const char _name[]; + static const char _enabled_key[]; + + // --------------------------------------------------------------------------- + // Bus HAL callbacks (static so they can be passed as function pointers) + // --------------------------------------------------------------------------- + static uint8_t busIsHigh() { + return digitalRead(_rxPinStatic); + } + static void busSetLow() { + digitalWrite(_txPinStatic, LOW); + } + static void busSetHigh() { + digitalWrite(_txPinStatic, HIGH); + } + + // Static copies of pins needed by the HAL callbacks + static int8_t _rxPinStatic; + static int8_t _txPinStatic; + + // --------------------------------------------------------------------------- + // Schedule a DALI backward frame to be sent after the mandatory settling time. + // DALI IEC 62386-102 requires 7Te (≈2.9ms) min, 22Te (≈9.2ms) max. + // We target 4ms — safely inside the window even with loop jitter. + // --------------------------------------------------------------------------- + void scheduleBF(uint8_t byte) { + _pendingBF = byte; + _pendingBFTime = millis() + 4; // 4ms after frame received in loop() + } + + // --------------------------------------------------------------------------- + // Apply a DALI arc level to WLED + // --------------------------------------------------------------------------- + void applyLevel(uint8_t daliLevel) { + _lastDaliLevel = daliLevel; + if (daliLevel == 0) { + briLast = bri ? bri : briLast; // preserve last brightness for toggle + bri = 0; + } else { + bri = daliLevelToWledBri(daliLevel); + } + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + + // --------------------------------------------------------------------------- + // Apply a colour temperature in mireds to WLED via strip.setCCT(Kelvin) + // --------------------------------------------------------------------------- + void applyCCT(uint16_t mireds) { + if (mireds == 0) return; // 0 mireds is undefined / mask value — ignore + // Convert mireds to Kelvin. Clamp to WLED's accepted range (1900–10091 K). + uint32_t kelvin = 1000000UL / mireds; + if (kelvin < 1900) kelvin = 1900; + if (kelvin > 10091) kelvin = 10091; + _lastCCTKelvin = (uint16_t)kelvin; + strip.setCCT(_lastCCTKelvin); + stateUpdated(CALL_MODE_DIRECT_CHANGE); + DEBUG_PRINTF("[DALI] CCT applied: %u mireds → %u K\n", mireds, (unsigned)kelvin); + } + + // --------------------------------------------------------------------------- + // Handle an indirect DALI command (S=1 in address byte) + // --------------------------------------------------------------------------- + void handleCommand(uint8_t cmd) { + switch (cmd) { + case DALI_OFF: + applyLevel(0); + break; + case DALI_UP: + if (bri > 0) { + bri = (bri > 245) ? 255 : bri + 10; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } + break; + case DALI_DOWN: + if (bri > 10) bri -= 10; + else if (bri > 0) bri = 1; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_RECALL_MAX_LEVEL: + bri = 255; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_RECALL_MIN_LEVEL: + bri = 1; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_GO_TO_LAST_ACTIVE_LEVEL: + bri = briLast ? briLast : 128; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_ON_AND_STEP_UP: + if (bri == 0) bri = 1; + else bri = (bri > 245) ? 255 : bri + 10; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_STEP_UP: + if (bri < 255) bri++; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_STEP_DOWN: + if (bri > 1) bri--; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_STEP_DOWN_AND_OFF: + if (bri <= 1) bri = 0; + else bri--; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + + // DT8 (IEC 62386-209) application extended commands. + // These are only valid when preceded by ENABLE DEVICE TYPE 8 (addr=0xC1, cmd=8). + // 0xE1 = SET TEMPORARY COLOUR TEMPERATURE — loads DTR0+DTR1 into temp register. + // 0xE2 = ACTIVATE — applies the temporary colour temperature. + // 0xE7 = QUERY COLOUR TYPE — master asks which DT8 colour modes are supported. + // Response bitmask (IEC 62386-209 §11.3.4.2): + // bit 0 = XY colour, bit 1 = Tc colour temperature, bit 2 = Primary N, + // bit 3 = RGBWAF. We support Tc only → respond 0x02. + case 0xE1: + if (_dt8Active) { + _tempCCT = ((uint16_t)_dtr1 << 8) | _dtr0; + DEBUG_PRINTF("[DALI] SET TEMPORARY COLOUR TEMPERATURE: %u mireds (DTR1=0x%02x DTR0=0x%02x)\n", + _tempCCT, _dtr1, _dtr0); + } else { + DEBUG_PRINTLN(F("[DALI] SET TEMPORARY COLOUR TEMPERATURE received but DT8 not active — ignored")); + } + break; + case 0xE2: + if (_dt8Active && _tempCCT > 0) { + DEBUG_PRINTF("[DALI] ACTIVATE: applying %u mireds\n", _tempCCT); + applyCCT(_tempCCT); + } else { + DEBUG_PRINTF("[DALI] ACTIVATE: skipped (dt8Active=%d tempCCT=%u)\n", _dt8Active, _tempCCT); + } + _dt8Active = false; + break; + + case 0xE7: + // QUERY COLOUR TYPE — respond regardless of _dt8Active state + // (master needs to know our capabilities before enabling DT8) + DEBUG_PRINTLN(F("[DALI] QUERY COLOUR TYPE → scheduling backward frame 0x02 (Tc supported)")); + scheduleBF(0x02); + break; + + default: + DEBUG_PRINTF("[DALI] unhandled command 0x%02x (%u) — ignored\n", cmd, cmd); + break; + } + } + + public: + + void setup() override { + if (!_enabled || _rxPin < 0 || _txPin < 0) { + _initDone = true; + return; + } + + // Claim pins via WLED pin manager + if (!PinManager::allocatePin(_rxPin, false, PinOwner::UM_DALI_GEAR) || + !PinManager::allocatePin(_txPin, true, PinOwner::UM_DALI_GEAR)) { + DEBUG_PRINTLN(F("[DALI] Pin allocation failed")); + _enabled = false; + _initDone = true; + return; + } + + // Configure GPIO + pinMode(_rxPin, INPUT); + pinMode(_txPin, OUTPUT); + digitalWrite(_txPin, HIGH); // idle bus state (not asserting bus) + + // Store static copies for HAL callbacks + _rxPinStatic = _rxPin; + _txPinStatic = _txPin; + + _dali.begin(busIsHigh, busSetLow, busSetHigh); + + // Hardware timer: IDF v4 API + // Timer 1 (timer 0 is used by SparkFunDMX), prescaler 80 → 1 MHz tick. + // Alarm at 104 ticks → ~9615 Hz ≈ 1200 baud × 8 oversample. + _daliTimer = timerBegin(1, 80, true); + timerAttachInterrupt(_daliTimer, &daliTimerISR, true); + timerAlarmWrite(_daliTimer, 104, true); + timerAlarmEnable(_daliTimer); + + DEBUG_PRINTF("[DALI] Gear usermod initialised (RX=%d TX=%d addr=%d)\n", + _rxPin, _txPin, _daliAddr); + _initDone = true; + } + + + void loop() override { + if (!_enabled || !_initDone || _rxPin < 0) return; + + // Send any pending backward frame once the settling window opens (≥7Te ≈ 2.9ms). + if (_pendingBF && (millis() >= _pendingBFTime)) { + uint8_t bf = _pendingBF; + _pendingBF = 0; + uint8_t result = _dali.tx(&bf, 8); + DEBUG_PRINTF("[DALI] backward frame 0x%02x sent (tx result=%u)\n", bf, result); + } + + uint8_t data[4]; + uint8_t bits = _dali.rx(data); + + if (bits == 0) return; // nothing received + + // A DALI forward frame is exactly 16 bits (2 bytes). + // 1-bit returns are normal bus-idle sampling noise from the library — discard silently. + // Log only genuinely unexpected lengths (partial frames: 3–15 bits). + if (bits != 16) { + if (bits > 2) { + DEBUG_PRINTF("[DALI] partial frame: %u bits (data: 0x%02x 0x%02x 0x%02x 0x%02x)\n", + bits, data[0], data[1], data[2], data[3]); + } + return; + } + + uint8_t addrByte = data[0]; + uint8_t cmdByte = data[1]; + + DEBUG_PRINTF("[DALI] raw frame: addr=0x%02x cmd=0x%02x\n", addrByte, cmdByte); + + // Sniff special broadcast commands that are NOT gear-addressed. + // These must be processed regardless of our _daliAddr setting. + // 0xA3 xx — SET DTR0 (Data Transfer Register 0) = xx + // 0xC3 xx — SET DTR1 (Data Transfer Register 1) = xx + // 0xC1 08 — ENABLE DEVICE TYPE 8 + if (addrByte == 0xA3) { + _dtr0 = cmdByte; + DEBUG_PRINTF("[DALI] SET DTR0 = 0x%02x (%u)\n", cmdByte, cmdByte); + return; + } + if (addrByte == 0xC3) { + _dtr1 = cmdByte; + DEBUG_PRINTF("[DALI] SET DTR1 = 0x%02x (%u)\n", cmdByte, cmdByte); + return; + } + if (addrByte == 0xC1) { + if (cmdByte == 8) { + _dt8Active = true; + DEBUG_PRINTLN(F("[DALI] ENABLE DEVICE TYPE 8")); + } else { + DEBUG_PRINTF("[DALI] ENABLE DEVICE TYPE %u (not handled)\n", cmdByte); + } + return; + } + + if (!daliAddressedToMe(addrByte, _daliAddr)) { + DEBUG_PRINTF("[DALI] frame not for us: addr=0x%02x (our addr=%d) — ignored\n", + addrByte, _daliAddr); + return; + } + + bool isDapc = !(addrByte & 0x01); // S bit = 0 → DAPC + + if (isDapc) { + if (cmdByte == 255) { + DEBUG_PRINTLN(F("[DALI] DAPC 255 (mask) — ignored")); + } else { + DEBUG_PRINTF("[DALI] DAPC level=%u → bri=%u\n", cmdByte, daliLevelToWledBri(cmdByte)); + applyLevel(cmdByte); + // Some masters use a non-standard combined flow: DTR0/DTR1 set the colour + // temperature, ENABLE DEVICE TYPE 8 arms it, and the subsequent DAPC applies + // both brightness and CCT in one go (without 0xE1+0xE2). + if (_dt8Active && (_dtr1 || _dtr0)) { + uint16_t mireds = ((uint16_t)_dtr1 << 8) | _dtr0; + applyCCT(mireds); + } + _dt8Active = false; + } + } else { + DEBUG_PRINTF("[DALI] command 0x%02x (%u)\n", cmdByte, cmdByte); + handleCommand(cmdByte); + } + } + + + void addToJsonInfo(JsonObject& root) override { + if (!_initDone) return; + JsonObject user = root["u"]; + if (user.isNull()) user = root.createNestedObject("u"); + + JsonArray arr = user.createNestedArray(FPSTR(_name)); + if (!_enabled) { + arr.add(F("disabled")); + return; + } + if (_rxPin < 0 || _txPin < 0) { + arr.add(F("pins not configured")); + return; + } + arr.add(_lastDaliLevel); + arr.add(F(" DALI level")); + if (_lastCCTKelvin > 0) { + JsonArray cctArr = user.createNestedArray(F("DALIGear CCT")); + cctArr.add(_lastCCTKelvin); + cctArr.add(F(" K")); + } + } + + + void addToConfig(JsonObject& root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top[FPSTR(_enabled_key)] = _enabled; + top["pin_rx"] = _rxPin; + top["pin_tx"] = _txPin; + top["daliAddr"] = _daliAddr; + } + + + bool readFromConfig(JsonObject& root) override { + JsonObject top = root[FPSTR(_name)]; + bool configComplete = !top.isNull(); + + configComplete &= getJsonValue(top[FPSTR(_enabled_key)], _enabled, false); + configComplete &= getJsonValue(top["pin_rx"], _rxPin, (int8_t)14); + configComplete &= getJsonValue(top["pin_tx"], _txPin, (int8_t)17); + configComplete &= getJsonValue(top["daliAddr"], _daliAddr, (int8_t)-1); + + return configComplete; + } + + + void appendConfigData() override { + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); + oappend(F(":pin_rx',1,'DALI RX pin');")); + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); + oappend(F(":pin_tx',1,'DALI TX pin');")); + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); + oappend(F(":daliAddr',1,'Short address (0\u201363) or -1 for broadcast only');")); + } + + + uint16_t getId() override { return USERMOD_ID_DALI_GEAR; } +}; + +// Static member definitions +int8_t DaliGearUsermod::_rxPinStatic = -1; +int8_t DaliGearUsermod::_txPinStatic = -1; + +const char DaliGearUsermod::_name[] PROGMEM = "DALIGear"; +const char DaliGearUsermod::_enabled_key[] PROGMEM = "enabled"; + +static DaliGearUsermod dali_gear_usermod; +REGISTER_USERMOD(dali_gear_usermod); diff --git a/wled00/const.h b/wled00/const.h index 00a6b4d226..70a5bb3dfe 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -252,6 +252,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_DALI_GEAR 59 //Usermod "usermod_dali_gear.cpp" //Wifi encryption type #ifdef WLED_ENABLE_WPA_ENTERPRISE diff --git a/wled00/pin_manager.h b/wled00/pin_manager.h index 7bdd5cfc20..3eb6ebc0d6 100644 --- a/wled00/pin_manager.h +++ b/wled00/pin_manager.h @@ -76,7 +76,8 @@ enum struct PinOwner : uint8_t { UM_LDR_DUSK_DAWN = USERMOD_ID_LDR_DUSK_DAWN, // 0x2B // Usermod "usermod_LDR_Dusk_Dawn_v2.h" UM_MAX17048 = USERMOD_ID_MAX17048, // 0x2F // Usermod "usermod_max17048.h" UM_BME68X = USERMOD_ID_BME68X, // 0x31 // Usermod "usermod_bme68x.h -- Uses "standard" HW_I2C pins - UM_PIXELS_DICE_TRAY = USERMOD_ID_PIXELS_DICE_TRAY // 0x35 // Usermod "pixels_dice_tray.h" -- Needs compile time specified 6 pins for display including SPI. + UM_PIXELS_DICE_TRAY = USERMOD_ID_PIXELS_DICE_TRAY, // 0x35 // Usermod "pixels_dice_tray.h" -- Needs compile time specified 6 pins for display including SPI. + UM_DALI_GEAR = USERMOD_ID_DALI_GEAR // 0x3B // Usermod "usermod_dali_gear.cpp" }; static_assert(0u == static_cast(PinOwner::None), "PinOwner::None must be zero, so default array initialization works as expected"); From e07b39ae4d5a0d4eefe779ea31093e82b069b319 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 24 May 2026 11:20:38 +0100 Subject: [PATCH 2/7] fix(dali_gear): address code review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - library.json: pin qqqDALI dependency to immutable commit SHA (e39a7da0) instead of branch ref for reproducible builds - readme.md: add 'text' language tag to ASCII circuit diagram fenced code block (fixes markdownlint MD040) - setup(): fix pin leak — deallocate RX pin if TX allocation fails --- usermods/dali_gear/library.json | 2 +- usermods/dali_gear/readme.md | 2 +- usermods/dali_gear/usermod_dali_gear.cpp | 12 +++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/usermods/dali_gear/library.json b/usermods/dali_gear/library.json index bfe6562fb6..865d94045d 100644 --- a/usermods/dali_gear/library.json +++ b/usermods/dali_gear/library.json @@ -2,6 +2,6 @@ "name": "dali_gear", "build": { "libArchive": false }, "dependencies": { - "qqqDALI": "https://github.com/netmindz/DALI-Lighting-Interface.git#fix/esp32-volatile-cast" + "qqqDALI": "https://github.com/netmindz/DALI-Lighting-Interface.git#e39a7da06242010bbb6771532c4ac17b3ec73834" } } diff --git a/usermods/dali_gear/readme.md b/usermods/dali_gear/readme.md index d300d8f49a..ad1e3e8bf0 100644 --- a/usermods/dali_gear/readme.md +++ b/usermods/dali_gear/readme.md @@ -12,7 +12,7 @@ You need a DALI bus interface circuit to convert between the DALI bus voltage (9 ### Minimal DIY circuit (from [qqqlab/DALI-Lighting-Interface](https://github.com/qqqlab/DALI-Lighting-Interface)) -``` +```text 3.3V ESP32 5.6V ___ Zener +----|___|---- 12V Power Supply ___ Diode | 220 Ω diff --git a/usermods/dali_gear/usermod_dali_gear.cpp b/usermods/dali_gear/usermod_dali_gear.cpp index f734395409..631b87f2eb 100644 --- a/usermods/dali_gear/usermod_dali_gear.cpp +++ b/usermods/dali_gear/usermod_dali_gear.cpp @@ -244,9 +244,15 @@ class DaliGearUsermod : public Usermod { } // Claim pins via WLED pin manager - if (!PinManager::allocatePin(_rxPin, false, PinOwner::UM_DALI_GEAR) || - !PinManager::allocatePin(_txPin, true, PinOwner::UM_DALI_GEAR)) { - DEBUG_PRINTLN(F("[DALI] Pin allocation failed")); + if (!PinManager::allocatePin(_rxPin, false, PinOwner::UM_DALI_GEAR)) { + DEBUG_PRINTLN(F("[DALI] RX pin allocation failed")); + _enabled = false; + _initDone = true; + return; + } + if (!PinManager::allocatePin(_txPin, true, PinOwner::UM_DALI_GEAR)) { + DEBUG_PRINTLN(F("[DALI] TX pin allocation failed")); + PinManager::deallocatePin(_rxPin, PinOwner::UM_DALI_GEAR); _enabled = false; _initDone = true; return; From 37fdce4b073b0197f1d9f4a628b92afa58e92a76 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 24 May 2026 11:29:28 +0100 Subject: [PATCH 3/7] =?UTF-8?q?feat(dali=5Fgear):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20TX=20invert,=20query=20responses,=20QUERY=20COLO?= =?UTF-8?q?UR=20TYPE=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tx_inverted config option: corrects TX polarity for single-stage inverting circuits (e.g. qqqDALI DIY PNP). Default false is correct for the Waveshare Pico-DALI2 (NPN + opto = double inversion). - Add QUERY STATUS (0x90), QUERY CONTROL GEAR PRESENT (0x91) and QUERY ACTUAL LEVEL (0xA0) backward frame responses, so masters that probe for gear presence before sending DT8 commands will find us. - Fix QUERY COLOUR TYPE command byte: spec (IEC 62386-209) uses 0xF7; handle both 0xF7 and 0xE7 (non-standard masters) via case fallthrough. - readme: warn that qqqDALI DIY PNP circuit is conceptual and not isolated; document tx_inverted setting and new query responses. --- usermods/dali_gear/readme.md | 19 +++- usermods/dali_gear/usermod_dali_gear.cpp | 116 +++++++++++++++++------ 2 files changed, 104 insertions(+), 31 deletions(-) diff --git a/usermods/dali_gear/readme.md b/usermods/dali_gear/readme.md index ad1e3e8bf0..4baaa3f5f0 100644 --- a/usermods/dali_gear/readme.md +++ b/usermods/dali_gear/readme.md @@ -26,9 +26,9 @@ RX ---+-----|___|---|>|----------+------------- DALI+ GND --+---------------------------+------------- DALI- ``` -> **Note:** For this circuit the TX polarity must be inverted. The transistor pulls the bus low when the GPIO is HIGH. Configure your TX pin accordingly and invert the output in hardware or adjust the HAL callbacks in the source. +> **⚠️ Warning:** This is a conceptual schematic for experimentation — it is **not isolated** and exposes the ESP32 GPIO to DALI bus voltages through a resistor divider only. Do not use it in a production installation. The PNP transistor produces a **single inversion**: GPIO HIGH drives the bus LOW (asserted). Enable **TX Inverted** in the usermod settings when using this circuit. -Commercial DALI interface modules (e.g. Waveshare Pico-DALI2, Mikroe DALI Click) are a simpler alternative. +Commercial DALI interface modules with proper isolation (e.g. Waveshare Pico-DALI2, Mikroe DALI Click) are strongly recommended for any real installation. ### Pin assignment @@ -48,6 +48,7 @@ Configure both pins in the WLED usermod settings page. | Enabled | false | Enable/disable the usermod | | pin_rx | 14 | GPIO for DALI bus RX | | pin_tx | 17 | GPIO for DALI bus TX | +| tx_inverted | false | Invert TX polarity. Enable for single-stage inverting circuits (e.g. DIY PNP). Leave off for Waveshare Pico-DALI2 and other NPN+opto-isolated boards. | | daliAddr | -1 | Short address (0–63) to respond to, or -1 to respond to broadcast only | ## DALI commands handled @@ -77,6 +78,16 @@ When the master sends a DAPC frame, the arc level (0–254) is mapped linearly t | ON AND STEP UP | 8 | Turn on if off, then increase by 10 | | GO TO LAST ACTIVE LEVEL | 10 | Restore last brightness before turn-off | +### Query commands (backward frame responses) + +These allow a DALI master to detect gear presence and read basic status. Responses are sent as DALI backward frames 4 ms after the query, within the IEC 62386-102 required window of 7Te–22Te (≈2.9–9.2 ms). + +| Command | Byte | Response | +|---|---|---| +| QUERY STATUS | 0x90 | Status byte: bit 2 = lamp on, bit 6 = no short address | +| QUERY CONTROL GEAR PRESENT | 0x91 | `0xFF` (Yes, I am here) | +| QUERY ACTUAL LEVEL | 0xA0 | Current arc level (0–254) derived from WLED brightness | + ### DT8 colour temperature (IEC 62386-209) Colour temperature commands from a DALI master are mapped to WLED's CCT value via `strip.setCCT()`. The mired value is converted to Kelvin (`K = 1,000,000 / mireds`). WLED's accepted range is 1900–10091 K; values outside this range are clamped. @@ -95,9 +106,9 @@ Two CCT application flows are supported: Some DALI masters skip the `0xE1` + `0xE2` sequence and instead apply the colour temperature implicitly alongside the subsequent DAPC command. The usermod detects this: if DTR0/DTR1 are set and DT8 is active when a DAPC frame arrives, the CCT is applied at the same time as the brightness change. -**QUERY COLOUR TYPE (0xE7):** +**QUERY COLOUR TYPE (0xF7 / 0xE7):** -Some masters query the gear's colour capabilities before sending CCT commands. The usermod responds with a backward frame value of `0x02` (bit 1 = Tc colour temperature supported), sent 4 ms after the query frame to meet the DALI spec requirement (7Te–22Te ≈ 2.9–9.2 ms settling window). +Some masters query the gear's colour capabilities before sending CCT commands. Per IEC 62386-209 §11.3.4.2, this command is `0xF7`. Some non-standard masters send `0xE7` instead; both are handled. The usermod responds with `0x02` (bit 1 = Tc colour temperature supported), sent 4 ms after the query frame to meet the DALI spec requirement (7Te–22Te ≈ 2.9–9.2 ms settling window). `SET DTR0`, `SET DTR1`, and `ENABLE DEVICE TYPE 8` are sniffed as broadcast-level frames regardless of the configured `daliAddr`. diff --git a/usermods/dali_gear/usermod_dali_gear.cpp b/usermods/dali_gear/usermod_dali_gear.cpp index 631b87f2eb..d51de2b106 100644 --- a/usermods/dali_gear/usermod_dali_gear.cpp +++ b/usermods/dali_gear/usermod_dali_gear.cpp @@ -9,7 +9,8 @@ // Phase 2: DT8 colour temperature (IEC 62386-209). Handles SET DTR0/DTR1 // special commands, ENABLE DEVICE TYPE 8, SET TEMPORARY COLOUR TEMPERATURE, // ACTIVATE to map DALI Tc (mireds) → WLED CCT (Kelvin), and backward frame -// response to QUERY COLOUR TYPE (0xe7) to declare Tc support to the master. +// responses to QUERY STATUS, QUERY CONTROL GEAR PRESENT, QUERY ACTUAL LEVEL, +// and QUERY COLOUR TYPE (0xF7 per spec; also 0xE7 for non-standard masters). // // Hardware: requires a DALI bus interface circuit (see readme.md). // ESP32 only — uses hardware timer API not available on ESP8266. @@ -36,7 +37,7 @@ static bool daliAddressedToMe(uint8_t addrByte, int8_t daliAddr) { uint8_t frameAddr = (addrByte >> 1) & 0x3F; return frameAddr == (uint8_t)daliAddr; } - // Group address: 100A AAA x — not handled in phase 1 + // Group address: 100A AAA x — not handled return false; } @@ -50,6 +51,12 @@ static uint8_t daliLevelToWledBri(uint8_t level) { return (uint8_t)(((uint16_t)level * 255u + 127u) / 254u); } +// Map WLED bri (1–255) back to a DALI arc level (1–254), for QUERY ACTUAL LEVEL. +static uint8_t wledBriToDaliLevel(uint8_t b) { + if (b == 0) return 0; + return (uint8_t)(((uint16_t)b * 254u + 127u) / 255u); +} + // --------------------------------------------------------------------------- // ISR and timer — file-scope so the ISR can reach the Dali instance // --------------------------------------------------------------------------- @@ -71,6 +78,10 @@ class DaliGearUsermod : public Usermod { bool _initDone = false; int8_t _rxPin = 14; // default: Waveshare Pico-DALI2 RX int8_t _txPin = 17; // default: Waveshare Pico-DALI2 TX + bool _txInverted = false; // true for circuits with a single-stage inverting TX driver + // (e.g. qqqDALI DIY PNP circuit). + // false (default) for the Waveshare Pico-DALI2 and other + // boards with double-inversion (NPN + opto-isolator). int8_t _daliAddr = -1; // -1 = respond to broadcast only uint8_t _lastDaliLevel = 0; // last DALI arc level received (for info panel) @@ -90,21 +101,29 @@ class DaliGearUsermod : public Usermod { static const char _enabled_key[]; // --------------------------------------------------------------------------- - // Bus HAL callbacks (static so they can be passed as function pointers) + // Bus HAL callbacks (static so they can be passed as function pointers). + // TX polarity depends on interface hardware: + // _txInverted = false (default): GPIO HIGH = bus idle, GPIO LOW = assert bus. + // Used by Waveshare Pico-DALI2 (NPN + opto-isolator = double inversion). + // _txInverted = true: GPIO LOW = bus idle, GPIO HIGH = assert bus. + // Used by the qqqDALI DIY PNP circuit (single inversion via PNP transistor). // --------------------------------------------------------------------------- static uint8_t busIsHigh() { return digitalRead(_rxPinStatic); } static void busSetLow() { - digitalWrite(_txPinStatic, LOW); + // "set bus low" = assert the DALI bus + digitalWrite(_txPinStatic, _txInvertedStatic ? HIGH : LOW); } static void busSetHigh() { - digitalWrite(_txPinStatic, HIGH); + // "set bus high" = release the DALI bus (idle) + digitalWrite(_txPinStatic, _txInvertedStatic ? LOW : HIGH); } - // Static copies of pins needed by the HAL callbacks + // Static copies of pins/config needed by the HAL callbacks static int8_t _rxPinStatic; static int8_t _txPinStatic; + static bool _txInvertedStatic; // --------------------------------------------------------------------------- // Schedule a DALI backward frame to be sent after the mandatory settling time. @@ -195,14 +214,48 @@ class DaliGearUsermod : public Usermod { stateUpdated(CALL_MODE_DIRECT_CHANGE); break; + // IEC 62386-102 §11.2 query commands — backward frame responses. + // These allow a DALI master to detect gear presence and read basic status + // before sending DT8 or other application commands. + + case 0x90: { + // QUERY STATUS — respond with status byte. + // Bit 2 = lamp arc power on (1 if bri > 0). + // Bit 6 = missing short address (1 if no address configured). + // All other status/fault bits = 0 (no failures to report). + uint8_t status = ((bri > 0) ? 0x04u : 0x00u) + | ((_daliAddr < 0) ? 0x40u : 0x00u); + DEBUG_PRINTF("[DALI] QUERY STATUS → 0x%02x\n", status); + scheduleBF(status); + break; + } + + case 0x91: + // QUERY CONTROL GEAR PRESENT — respond 0xFF ("Yes"). + // Many masters send this first to detect whether any gear is on the bus; + // silence here causes the master to skip all subsequent commands. + DEBUG_PRINTLN(F("[DALI] QUERY CONTROL GEAR PRESENT → 0xFF")); + scheduleBF(0xFF); + break; + + case 0xA0: + // QUERY ACTUAL LEVEL — respond with current arc level (0–254). + // Derived from the current WLED brightness so it stays accurate even if + // bri was changed via the WLED UI rather than a DALI command. + DEBUG_PRINTF("[DALI] QUERY ACTUAL LEVEL → %u\n", wledBriToDaliLevel(bri)); + scheduleBF(wledBriToDaliLevel(bri)); + break; + // DT8 (IEC 62386-209) application extended commands. // These are only valid when preceded by ENABLE DEVICE TYPE 8 (addr=0xC1, cmd=8). // 0xE1 = SET TEMPORARY COLOUR TEMPERATURE — loads DTR0+DTR1 into temp register. // 0xE2 = ACTIVATE — applies the temporary colour temperature. - // 0xE7 = QUERY COLOUR TYPE — master asks which DT8 colour modes are supported. - // Response bitmask (IEC 62386-209 §11.3.4.2): - // bit 0 = XY colour, bit 1 = Tc colour temperature, bit 2 = Primary N, - // bit 3 = RGBWAF. We support Tc only → respond 0x02. + // 0xF7 = QUERY COLOUR TYPE (IEC 62386-209 §11.3.4.2) — master asks which DT8 + // colour modes are supported. Response bitmask: + // bit 0 = XY colour, bit 1 = Tc colour temperature, + // bit 2 = Primary N, bit 3 = RGBWAF. We support Tc only → 0x02. + // Note: some non-standard masters send this as 0xE7 instead. Both are + // handled here to maximise interoperability. case 0xE1: if (_dt8Active) { _tempCCT = ((uint16_t)_dtr1 << 8) | _dtr0; @@ -222,9 +275,10 @@ class DaliGearUsermod : public Usermod { _dt8Active = false; break; - case 0xE7: - // QUERY COLOUR TYPE — respond regardless of _dt8Active state - // (master needs to know our capabilities before enabling DT8) + case 0xE7: // non-standard masters send QUERY COLOUR TYPE here (spec says 0xF7) + case 0xF7: // QUERY COLOUR TYPE — IEC 62386-209 §11.3.4.2 + // Respond regardless of _dt8Active state; master needs to know our + // capabilities before it will send ENABLE DEVICE TYPE 8. DEBUG_PRINTLN(F("[DALI] QUERY COLOUR TYPE → scheduling backward frame 0x02 (Tc supported)")); scheduleBF(0x02); break; @@ -261,11 +315,13 @@ class DaliGearUsermod : public Usermod { // Configure GPIO pinMode(_rxPin, INPUT); pinMode(_txPin, OUTPUT); - digitalWrite(_txPin, HIGH); // idle bus state (not asserting bus) + // Idle state: bus not asserted. Polarity depends on interface circuit. + digitalWrite(_txPin, _txInverted ? LOW : HIGH); // Store static copies for HAL callbacks - _rxPinStatic = _rxPin; - _txPinStatic = _txPin; + _rxPinStatic = _rxPin; + _txPinStatic = _txPin; + _txInvertedStatic = _txInverted; _dali.begin(busIsHigh, busSetLow, busSetHigh); @@ -277,8 +333,8 @@ class DaliGearUsermod : public Usermod { timerAlarmWrite(_daliTimer, 104, true); timerAlarmEnable(_daliTimer); - DEBUG_PRINTF("[DALI] Gear usermod initialised (RX=%d TX=%d addr=%d)\n", - _rxPin, _txPin, _daliAddr); + DEBUG_PRINTF("[DALI] Gear usermod initialised (RX=%d TX=%d txInv=%d addr=%d)\n", + _rxPin, _txPin, (int)_txInverted, _daliAddr); _initDone = true; } @@ -397,9 +453,10 @@ class DaliGearUsermod : public Usermod { void addToConfig(JsonObject& root) override { JsonObject top = root.createNestedObject(FPSTR(_name)); top[FPSTR(_enabled_key)] = _enabled; - top["pin_rx"] = _rxPin; - top["pin_tx"] = _txPin; - top["daliAddr"] = _daliAddr; + top["pin_rx"] = _rxPin; + top["pin_tx"] = _txPin; + top["tx_inverted"] = _txInverted; + top["daliAddr"] = _daliAddr; } @@ -407,10 +464,11 @@ class DaliGearUsermod : public Usermod { JsonObject top = root[FPSTR(_name)]; bool configComplete = !top.isNull(); - configComplete &= getJsonValue(top[FPSTR(_enabled_key)], _enabled, false); - configComplete &= getJsonValue(top["pin_rx"], _rxPin, (int8_t)14); - configComplete &= getJsonValue(top["pin_tx"], _txPin, (int8_t)17); - configComplete &= getJsonValue(top["daliAddr"], _daliAddr, (int8_t)-1); + configComplete &= getJsonValue(top[FPSTR(_enabled_key)], _enabled, false); + configComplete &= getJsonValue(top["pin_rx"], _rxPin, (int8_t)14); + configComplete &= getJsonValue(top["pin_tx"], _txPin, (int8_t)17); + configComplete &= getJsonValue(top["tx_inverted"], _txInverted, false); + configComplete &= getJsonValue(top["daliAddr"], _daliAddr, (int8_t)-1); return configComplete; } @@ -425,6 +483,9 @@ class DaliGearUsermod : public Usermod { oappend(F(":pin_tx',1,'DALI TX pin');")); oappend(F("addInfo('")); oappend(String(FPSTR(_name)).c_str()); + oappend(F(":tx_inverted',1,'Invert TX — enable for single-stage inverting circuits (e.g. DIY PNP). Leave off for Waveshare Pico-DALI2 and NPN+opto boards.');")); + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); oappend(F(":daliAddr',1,'Short address (0\u201363) or -1 for broadcast only');")); } @@ -433,8 +494,9 @@ class DaliGearUsermod : public Usermod { }; // Static member definitions -int8_t DaliGearUsermod::_rxPinStatic = -1; -int8_t DaliGearUsermod::_txPinStatic = -1; +int8_t DaliGearUsermod::_rxPinStatic = -1; +int8_t DaliGearUsermod::_txPinStatic = -1; +bool DaliGearUsermod::_txInvertedStatic = false; const char DaliGearUsermod::_name[] PROGMEM = "DALIGear"; const char DaliGearUsermod::_enabled_key[] PROGMEM = "enabled"; From 1da5eb3c85136e0ceaaaf61d06580a627e79017a Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 24 May 2026 11:33:23 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix(dali=5Fgear):=20do=20not=20respond=20to?= =?UTF-8?q?=200xE7=20=E2=80=94=20QUERY=20COLOUR=20TYPE=20is=200xF7=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per IEC 62386-209, QUERY COLOUR TYPE is command 0xF7. Command 0xE7 is not a query and must not produce a backward frame response. The previous 0xE7 fallthrough was added for a non-standard master but violates the spec. Now that 0x90/0x91/0xA0 presence queries are handled, a compliant master will discover the gear via those and correctly use 0xF7. --- usermods/dali_gear/readme.md | 4 ++-- usermods/dali_gear/usermod_dali_gear.cpp | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/usermods/dali_gear/readme.md b/usermods/dali_gear/readme.md index 4baaa3f5f0..75cbc8087c 100644 --- a/usermods/dali_gear/readme.md +++ b/usermods/dali_gear/readme.md @@ -106,9 +106,9 @@ Two CCT application flows are supported: Some DALI masters skip the `0xE1` + `0xE2` sequence and instead apply the colour temperature implicitly alongside the subsequent DAPC command. The usermod detects this: if DTR0/DTR1 are set and DT8 is active when a DAPC frame arrives, the CCT is applied at the same time as the brightness change. -**QUERY COLOUR TYPE (0xF7 / 0xE7):** +**QUERY COLOUR TYPE (0xF7):** -Some masters query the gear's colour capabilities before sending CCT commands. Per IEC 62386-209 §11.3.4.2, this command is `0xF7`. Some non-standard masters send `0xE7` instead; both are handled. The usermod responds with `0x02` (bit 1 = Tc colour temperature supported), sent 4 ms after the query frame to meet the DALI spec requirement (7Te–22Te ≈ 2.9–9.2 ms settling window). +Some masters query the gear's colour capabilities before sending CCT commands. Per IEC 62386-209 §11.3.4.2, this command is `0xF7`. The usermod responds with `0x02` (bit 1 = Tc colour temperature supported), sent 4 ms after the query frame to meet the DALI spec requirement (7Te–22Te ≈ 2.9–9.2 ms settling window). Command `0xE7` does not generate a backward frame response (it is not a query command per the spec). `SET DTR0`, `SET DTR1`, and `ENABLE DEVICE TYPE 8` are sniffed as broadcast-level frames regardless of the configured `daliAddr`. diff --git a/usermods/dali_gear/usermod_dali_gear.cpp b/usermods/dali_gear/usermod_dali_gear.cpp index d51de2b106..b4282489a5 100644 --- a/usermods/dali_gear/usermod_dali_gear.cpp +++ b/usermods/dali_gear/usermod_dali_gear.cpp @@ -10,7 +10,7 @@ // special commands, ENABLE DEVICE TYPE 8, SET TEMPORARY COLOUR TEMPERATURE, // ACTIVATE to map DALI Tc (mireds) → WLED CCT (Kelvin), and backward frame // responses to QUERY STATUS, QUERY CONTROL GEAR PRESENT, QUERY ACTUAL LEVEL, -// and QUERY COLOUR TYPE (0xF7 per spec; also 0xE7 for non-standard masters). +// and QUERY COLOUR TYPE (0xF7 per IEC 62386-209). // // Hardware: requires a DALI bus interface circuit (see readme.md). // ESP32 only — uses hardware timer API not available on ESP8266. @@ -275,11 +275,18 @@ class DaliGearUsermod : public Usermod { _dt8Active = false; break; - case 0xE7: // non-standard masters send QUERY COLOUR TYPE here (spec says 0xF7) + case 0xE7: + // 0xE7 is not QUERY COLOUR TYPE per IEC 62386-209 — do not respond. + // (QUERY COLOUR TYPE is 0xF7; some non-standard masters mistakenly use + // 0xE7, but sending a backward frame here would violate the spec.) + DEBUG_PRINTLN(F("[DALI] cmd 0xE7 (not a query — no response)")); + break; + case 0xF7: // QUERY COLOUR TYPE — IEC 62386-209 §11.3.4.2 // Respond regardless of _dt8Active state; master needs to know our // capabilities before it will send ENABLE DEVICE TYPE 8. - DEBUG_PRINTLN(F("[DALI] QUERY COLOUR TYPE → scheduling backward frame 0x02 (Tc supported)")); + // Response bitmask: bit 1 = Tc colour temperature supported → 0x02. + DEBUG_PRINTLN(F("[DALI] QUERY COLOUR TYPE (0xF7) → scheduling backward frame 0x02 (Tc supported)")); scheduleBF(0x02); break; From 80c4bf891bc510ce9995c1f4543eb1e2820a06b5 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sun, 24 May 2026 11:35:22 +0100 Subject: [PATCH 5/7] feat(dali_gear): respond to QUERY DEVICE TYPE (0x18) with 0x08 Conformant DALI-2 masters send QUERY DEVICE TYPE before issuing ENABLE DEVICE TYPE 8 or any DT8 application extended commands. Without this response, such masters skip CCT control entirely. Update readme query-responses table and top-of-file comment. --- usermods/dali_gear/readme.md | 1 + usermods/dali_gear/usermod_dali_gear.cpp | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/usermods/dali_gear/readme.md b/usermods/dali_gear/readme.md index 75cbc8087c..8daa8ff68b 100644 --- a/usermods/dali_gear/readme.md +++ b/usermods/dali_gear/readme.md @@ -86,6 +86,7 @@ These allow a DALI master to detect gear presence and read basic status. Respons |---|---|---| | QUERY STATUS | 0x90 | Status byte: bit 2 = lamp on, bit 6 = no short address | | QUERY CONTROL GEAR PRESENT | 0x91 | `0xFF` (Yes, I am here) | +| QUERY DEVICE TYPE | 0x18 | `0x08` (device type 8 = colour control) | | QUERY ACTUAL LEVEL | 0xA0 | Current arc level (0–254) derived from WLED brightness | ### DT8 colour temperature (IEC 62386-209) diff --git a/usermods/dali_gear/usermod_dali_gear.cpp b/usermods/dali_gear/usermod_dali_gear.cpp index b4282489a5..81e04d54b1 100644 --- a/usermods/dali_gear/usermod_dali_gear.cpp +++ b/usermods/dali_gear/usermod_dali_gear.cpp @@ -9,7 +9,8 @@ // Phase 2: DT8 colour temperature (IEC 62386-209). Handles SET DTR0/DTR1 // special commands, ENABLE DEVICE TYPE 8, SET TEMPORARY COLOUR TEMPERATURE, // ACTIVATE to map DALI Tc (mireds) → WLED CCT (Kelvin), and backward frame -// responses to QUERY STATUS, QUERY CONTROL GEAR PRESENT, QUERY ACTUAL LEVEL, +// responses to QUERY STATUS (0x90), QUERY CONTROL GEAR PRESENT (0x91), +// QUERY DEVICE TYPE (0x18), QUERY ACTUAL LEVEL (0xA0), // and QUERY COLOUR TYPE (0xF7 per IEC 62386-209). // // Hardware: requires a DALI bus interface circuit (see readme.md). @@ -238,6 +239,14 @@ class DaliGearUsermod : public Usermod { scheduleBF(0xFF); break; + case 0x18: + // QUERY DEVICE TYPE — respond 0x08 (device type 8 = colour control, IEC 62386-209). + // Conformant DALI-2 masters send this before issuing ENABLE DEVICE TYPE 8 or any + // DT8 application extended commands. Silence causes such masters to skip CCT control. + DEBUG_PRINTLN(F("[DALI] QUERY DEVICE TYPE → 0x08")); + scheduleBF(0x08); + break; + case 0xA0: // QUERY ACTUAL LEVEL — respond with current arc level (0–254). // Derived from the current WLED brightness so it stays accurate even if From 91a6815ee2a9a9393795003cdd985f10b43873a1 Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 12 Sep 2026 13:41:19 +0100 Subject: [PATCH 6/7] fix(dali_gear): update ESP32 timer API for arduino-esp32 core 3.x CI failed to compile: timerBegin()/timerAttachInterrupt()/timerAlarmWrite()/ timerAlarmEnable() used the removed IDF v4-era signatures. WLED's ESP32 build now targets arduino-esp32 core 3.x (IDF v5), which uses timerBegin(freq_hz), timerAttachInterrupt(timer, cb) and a combined timerAlarm(timer, value, autoreload, reload_count). Also bump the pinned qqqDALI (DALI-Lighting-Interface) commit to pick up two upstream build fixes needed for the same newer toolchain: - missing return in read_memory_bank() (-Werror=return-type) - invalid volatile-qualified pointer conversion in rx() Note: the file was also reformatted to the project's .clang-format style by an IDE auto-format-on-save; the functional change is limited to the timer API in setup(). --- usermods/dali_gear/library.json | 2 +- usermods/dali_gear/usermod_dali_gear.cpp | 593 +++++++++++------------ 2 files changed, 291 insertions(+), 304 deletions(-) diff --git a/usermods/dali_gear/library.json b/usermods/dali_gear/library.json index 865d94045d..533ff0f710 100644 --- a/usermods/dali_gear/library.json +++ b/usermods/dali_gear/library.json @@ -2,6 +2,6 @@ "name": "dali_gear", "build": { "libArchive": false }, "dependencies": { - "qqqDALI": "https://github.com/netmindz/DALI-Lighting-Interface.git#e39a7da06242010bbb6771532c4ac17b3ec73834" + "qqqDALI": "https://github.com/netmindz/DALI-Lighting-Interface.git#b260a6e6a3509ce9fcc1e8b384573628ae720c93" } } diff --git a/usermods/dali_gear/usermod_dali_gear.cpp b/usermods/dali_gear/usermod_dali_gear.cpp index 81e04d54b1..cb0b3461c6 100644 --- a/usermods/dali_gear/usermod_dali_gear.cpp +++ b/usermods/dali_gear/usermod_dali_gear.cpp @@ -63,7 +63,7 @@ static uint8_t wledBriToDaliLevel(uint8_t b) { // --------------------------------------------------------------------------- static Dali _dali; -static hw_timer_t *_daliTimer = nullptr; +static hw_timer_t* _daliTimer = nullptr; static void ARDUINO_ISR_ATTR daliTimerISR() { _dali.timer(); @@ -74,32 +74,32 @@ static void ARDUINO_ISR_ATTR daliTimerISR() { // --------------------------------------------------------------------------- class DaliGearUsermod : public Usermod { - private: - bool _enabled = false; - bool _initDone = false; - int8_t _rxPin = 14; // default: Waveshare Pico-DALI2 RX - int8_t _txPin = 17; // default: Waveshare Pico-DALI2 TX - bool _txInverted = false; // true for circuits with a single-stage inverting TX driver + private: + bool _enabled = false; + bool _initDone = false; + int8_t _rxPin = 14; // default: Waveshare Pico-DALI2 RX + int8_t _txPin = 17; // default: Waveshare Pico-DALI2 TX + bool _txInverted = false; // true for circuits with a single-stage inverting TX driver // (e.g. qqqDALI DIY PNP circuit). // false (default) for the Waveshare Pico-DALI2 and other // boards with double-inversion (NPN + opto-isolator). - int8_t _daliAddr = -1; // -1 = respond to broadcast only - uint8_t _lastDaliLevel = 0; // last DALI arc level received (for info panel) + int8_t _daliAddr = -1; // -1 = respond to broadcast only + uint8_t _lastDaliLevel = 0; // last DALI arc level received (for info panel) // DT8 (IEC 62386-209) colour temperature state - uint8_t _dtr0 = 0; // Data Transfer Register 0 (low byte of Tc mireds) - uint8_t _dtr1 = 0; // Data Transfer Register 1 (high byte of Tc mireds) - bool _dt8Active = false; // true after ENABLE DEVICE TYPE 8 - uint16_t _tempCCT = 0; // temporary colour temperature register (mireds) - uint16_t _lastCCTKelvin = 0; // last applied CCT in Kelvin (for info panel) + uint8_t _dtr0 = 0; // Data Transfer Register 0 (low byte of Tc mireds) + uint8_t _dtr1 = 0; // Data Transfer Register 1 (high byte of Tc mireds) + bool _dt8Active = false; // true after ENABLE DEVICE TYPE 8 + uint16_t _tempCCT = 0; // temporary colour temperature register (mireds) + uint16_t _lastCCTKelvin = 0; // last applied CCT in Kelvin (for info panel) // Backward frame scheduling — DALI requires response 7Te–22Te (≈2.9–9.2ms) // after the forward frame stop bits. We schedule via timestamp. - uint8_t _pendingBF = 0; // backward frame byte to send (0 = none pending) - uint32_t _pendingBFTime = 0; // millis() threshold — send when now >= this + uint8_t _pendingBF = 0; // backward frame byte to send (0 = none pending) + uint32_t _pendingBFTime = 0; // millis() threshold — send when now >= this - static const char _name[]; - static const char _enabled_key[]; + static const char _name[]; + static const char _enabled_key[]; // --------------------------------------------------------------------------- // Bus HAL callbacks (static so they can be passed as function pointers). @@ -109,151 +109,146 @@ class DaliGearUsermod : public Usermod { // _txInverted = true: GPIO LOW = bus idle, GPIO HIGH = assert bus. // Used by the qqqDALI DIY PNP circuit (single inversion via PNP transistor). // --------------------------------------------------------------------------- - static uint8_t busIsHigh() { - return digitalRead(_rxPinStatic); - } - static void busSetLow() { + static uint8_t busIsHigh() { return digitalRead(_rxPinStatic); } + static void busSetLow() { // "set bus low" = assert the DALI bus - digitalWrite(_txPinStatic, _txInvertedStatic ? HIGH : LOW); - } - static void busSetHigh() { + digitalWrite(_txPinStatic, _txInvertedStatic ? HIGH : LOW); + } + static void busSetHigh() { // "set bus high" = release the DALI bus (idle) - digitalWrite(_txPinStatic, _txInvertedStatic ? LOW : HIGH); - } + digitalWrite(_txPinStatic, _txInvertedStatic ? LOW : HIGH); + } // Static copies of pins/config needed by the HAL callbacks - static int8_t _rxPinStatic; - static int8_t _txPinStatic; - static bool _txInvertedStatic; + static int8_t _rxPinStatic; + static int8_t _txPinStatic; + static bool _txInvertedStatic; // --------------------------------------------------------------------------- // Schedule a DALI backward frame to be sent after the mandatory settling time. // DALI IEC 62386-102 requires 7Te (≈2.9ms) min, 22Te (≈9.2ms) max. // We target 4ms — safely inside the window even with loop jitter. // --------------------------------------------------------------------------- - void scheduleBF(uint8_t byte) { - _pendingBF = byte; - _pendingBFTime = millis() + 4; // 4ms after frame received in loop() - } + void scheduleBF(uint8_t byte) { + _pendingBF = byte; + _pendingBFTime = millis() + 4; // 4ms after frame received in loop() + } // --------------------------------------------------------------------------- // Apply a DALI arc level to WLED // --------------------------------------------------------------------------- - void applyLevel(uint8_t daliLevel) { - _lastDaliLevel = daliLevel; - if (daliLevel == 0) { - briLast = bri ? bri : briLast; // preserve last brightness for toggle - bri = 0; - } else { - bri = daliLevelToWledBri(daliLevel); - } - stateUpdated(CALL_MODE_DIRECT_CHANGE); + void applyLevel(uint8_t daliLevel) { + _lastDaliLevel = daliLevel; + if (daliLevel == 0) { + briLast = bri ? bri : briLast; // preserve last brightness for toggle + bri = 0; + } else { + bri = daliLevelToWledBri(daliLevel); } + stateUpdated(CALL_MODE_DIRECT_CHANGE); + } // --------------------------------------------------------------------------- // Apply a colour temperature in mireds to WLED via strip.setCCT(Kelvin) // --------------------------------------------------------------------------- - void applyCCT(uint16_t mireds) { - if (mireds == 0) return; // 0 mireds is undefined / mask value — ignore + void applyCCT(uint16_t mireds) { + if (mireds == 0) return; // 0 mireds is undefined / mask value — ignore // Convert mireds to Kelvin. Clamp to WLED's accepted range (1900–10091 K). - uint32_t kelvin = 1000000UL / mireds; - if (kelvin < 1900) kelvin = 1900; - if (kelvin > 10091) kelvin = 10091; - _lastCCTKelvin = (uint16_t)kelvin; - strip.setCCT(_lastCCTKelvin); - stateUpdated(CALL_MODE_DIRECT_CHANGE); - DEBUG_PRINTF("[DALI] CCT applied: %u mireds → %u K\n", mireds, (unsigned)kelvin); - } + uint32_t kelvin = 1000000UL / mireds; + if (kelvin < 1900) kelvin = 1900; + if (kelvin > 10091) kelvin = 10091; + _lastCCTKelvin = (uint16_t)kelvin; + strip.setCCT(_lastCCTKelvin); + stateUpdated(CALL_MODE_DIRECT_CHANGE); + DEBUG_PRINTF("[DALI] CCT applied: %u mireds → %u K\n", mireds, (unsigned)kelvin); + } // --------------------------------------------------------------------------- // Handle an indirect DALI command (S=1 in address byte) // --------------------------------------------------------------------------- - void handleCommand(uint8_t cmd) { - switch (cmd) { - case DALI_OFF: - applyLevel(0); - break; - case DALI_UP: - if (bri > 0) { - bri = (bri > 245) ? 255 : bri + 10; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - } - break; - case DALI_DOWN: - if (bri > 10) bri -= 10; - else if (bri > 0) bri = 1; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; - case DALI_RECALL_MAX_LEVEL: - bri = 255; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; - case DALI_RECALL_MIN_LEVEL: - bri = 1; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; - case DALI_GO_TO_LAST_ACTIVE_LEVEL: - bri = briLast ? briLast : 128; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; - case DALI_ON_AND_STEP_UP: - if (bri == 0) bri = 1; - else bri = (bri > 245) ? 255 : bri + 10; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; - case DALI_STEP_UP: - if (bri < 255) bri++; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; - case DALI_STEP_DOWN: - if (bri > 1) bri--; - stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; - case DALI_STEP_DOWN_AND_OFF: - if (bri <= 1) bri = 0; - else bri--; + void handleCommand(uint8_t cmd) { + switch (cmd) { + case DALI_OFF: applyLevel(0); break; + case DALI_UP: + if (bri > 0) { + bri = (bri > 245) ? 255 : bri + 10; stateUpdated(CALL_MODE_DIRECT_CHANGE); - break; + } + break; + case DALI_DOWN: + if (bri > 10) bri -= 10; + else if (bri > 0) bri = 1; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_RECALL_MAX_LEVEL: + bri = 255; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_RECALL_MIN_LEVEL: + bri = 1; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_GO_TO_LAST_ACTIVE_LEVEL: + bri = briLast ? briLast : 128; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_ON_AND_STEP_UP: + if (bri == 0) bri = 1; + else bri = (bri > 245) ? 255 : bri + 10; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_STEP_UP: + if (bri < 255) bri++; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_STEP_DOWN: + if (bri > 1) bri--; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; + case DALI_STEP_DOWN_AND_OFF: + if (bri <= 1) bri = 0; + else bri--; + stateUpdated(CALL_MODE_DIRECT_CHANGE); + break; // IEC 62386-102 §11.2 query commands — backward frame responses. // These allow a DALI master to detect gear presence and read basic status // before sending DT8 or other application commands. - case 0x90: { + case 0x90: { // QUERY STATUS — respond with status byte. // Bit 2 = lamp arc power on (1 if bri > 0). // Bit 6 = missing short address (1 if no address configured). // All other status/fault bits = 0 (no failures to report). - uint8_t status = ((bri > 0) ? 0x04u : 0x00u) - | ((_daliAddr < 0) ? 0x40u : 0x00u); - DEBUG_PRINTF("[DALI] QUERY STATUS → 0x%02x\n", status); - scheduleBF(status); - break; - } + uint8_t status = ((bri > 0) ? 0x04u : 0x00u) | ((_daliAddr < 0) ? 0x40u : 0x00u); + DEBUG_PRINTF("[DALI] QUERY STATUS → 0x%02x\n", status); + scheduleBF(status); + break; + } - case 0x91: + case 0x91: // QUERY CONTROL GEAR PRESENT — respond 0xFF ("Yes"). // Many masters send this first to detect whether any gear is on the bus; // silence here causes the master to skip all subsequent commands. - DEBUG_PRINTLN(F("[DALI] QUERY CONTROL GEAR PRESENT → 0xFF")); - scheduleBF(0xFF); - break; + DEBUG_PRINTLN(F("[DALI] QUERY CONTROL GEAR PRESENT → 0xFF")); + scheduleBF(0xFF); + break; - case 0x18: + case 0x18: // QUERY DEVICE TYPE — respond 0x08 (device type 8 = colour control, IEC 62386-209). // Conformant DALI-2 masters send this before issuing ENABLE DEVICE TYPE 8 or any // DT8 application extended commands. Silence causes such masters to skip CCT control. - DEBUG_PRINTLN(F("[DALI] QUERY DEVICE TYPE → 0x08")); - scheduleBF(0x08); - break; + DEBUG_PRINTLN(F("[DALI] QUERY DEVICE TYPE → 0x08")); + scheduleBF(0x08); + break; - case 0xA0: + case 0xA0: // QUERY ACTUAL LEVEL — respond with current arc level (0–254). // Derived from the current WLED brightness so it stays accurate even if // bri was changed via the WLED UI rather than a DALI command. - DEBUG_PRINTF("[DALI] QUERY ACTUAL LEVEL → %u\n", wledBriToDaliLevel(bri)); - scheduleBF(wledBriToDaliLevel(bri)); - break; + DEBUG_PRINTF("[DALI] QUERY ACTUAL LEVEL → %u\n", wledBriToDaliLevel(bri)); + scheduleBF(wledBriToDaliLevel(bri)); + break; // DT8 (IEC 62386-209) application extended commands. // These are only valid when preceded by ENABLE DEVICE TYPE 8 (addr=0xC1, cmd=8). @@ -265,256 +260,248 @@ class DaliGearUsermod : public Usermod { // bit 2 = Primary N, bit 3 = RGBWAF. We support Tc only → 0x02. // Note: some non-standard masters send this as 0xE7 instead. Both are // handled here to maximise interoperability. - case 0xE1: - if (_dt8Active) { - _tempCCT = ((uint16_t)_dtr1 << 8) | _dtr0; - DEBUG_PRINTF("[DALI] SET TEMPORARY COLOUR TEMPERATURE: %u mireds (DTR1=0x%02x DTR0=0x%02x)\n", - _tempCCT, _dtr1, _dtr0); - } else { - DEBUG_PRINTLN(F("[DALI] SET TEMPORARY COLOUR TEMPERATURE received but DT8 not active — ignored")); - } - break; - case 0xE2: - if (_dt8Active && _tempCCT > 0) { - DEBUG_PRINTF("[DALI] ACTIVATE: applying %u mireds\n", _tempCCT); - applyCCT(_tempCCT); - } else { - DEBUG_PRINTF("[DALI] ACTIVATE: skipped (dt8Active=%d tempCCT=%u)\n", _dt8Active, _tempCCT); - } - _dt8Active = false; - break; - - case 0xE7: + case 0xE1: + if (_dt8Active) { + _tempCCT = ((uint16_t)_dtr1 << 8) | _dtr0; + DEBUG_PRINTF("[DALI] SET TEMPORARY COLOUR TEMPERATURE: %u mireds (DTR1=0x%02x DTR0=0x%02x)\n", _tempCCT, _dtr1, _dtr0); + } else { + DEBUG_PRINTLN(F("[DALI] SET TEMPORARY COLOUR TEMPERATURE received but DT8 not active — ignored")); + } + break; + case 0xE2: + if (_dt8Active && _tempCCT > 0) { + DEBUG_PRINTF("[DALI] ACTIVATE: applying %u mireds\n", _tempCCT); + applyCCT(_tempCCT); + } else { + DEBUG_PRINTF("[DALI] ACTIVATE: skipped (dt8Active=%d tempCCT=%u)\n", _dt8Active, _tempCCT); + } + _dt8Active = false; + break; + + case 0xE7: // 0xE7 is not QUERY COLOUR TYPE per IEC 62386-209 — do not respond. // (QUERY COLOUR TYPE is 0xF7; some non-standard masters mistakenly use // 0xE7, but sending a backward frame here would violate the spec.) - DEBUG_PRINTLN(F("[DALI] cmd 0xE7 (not a query — no response)")); - break; + DEBUG_PRINTLN(F("[DALI] cmd 0xE7 (not a query — no response)")); + break; - case 0xF7: // QUERY COLOUR TYPE — IEC 62386-209 §11.3.4.2 + case 0xF7: // QUERY COLOUR TYPE — IEC 62386-209 §11.3.4.2 // Respond regardless of _dt8Active state; master needs to know our // capabilities before it will send ENABLE DEVICE TYPE 8. // Response bitmask: bit 1 = Tc colour temperature supported → 0x02. - DEBUG_PRINTLN(F("[DALI] QUERY COLOUR TYPE (0xF7) → scheduling backward frame 0x02 (Tc supported)")); - scheduleBF(0x02); - break; + DEBUG_PRINTLN(F("[DALI] QUERY COLOUR TYPE (0xF7) → scheduling backward frame 0x02 (Tc supported)")); + scheduleBF(0x02); + break; - default: - DEBUG_PRINTF("[DALI] unhandled command 0x%02x (%u) — ignored\n", cmd, cmd); - break; - } + default: DEBUG_PRINTF("[DALI] unhandled command 0x%02x (%u) — ignored\n", cmd, cmd); break; } + } - public: - - void setup() override { - if (!_enabled || _rxPin < 0 || _txPin < 0) { - _initDone = true; - return; - } + public: + void setup() override { + if (!_enabled || _rxPin < 0 || _txPin < 0) { + _initDone = true; + return; + } // Claim pins via WLED pin manager - if (!PinManager::allocatePin(_rxPin, false, PinOwner::UM_DALI_GEAR)) { - DEBUG_PRINTLN(F("[DALI] RX pin allocation failed")); - _enabled = false; - _initDone = true; - return; - } - if (!PinManager::allocatePin(_txPin, true, PinOwner::UM_DALI_GEAR)) { - DEBUG_PRINTLN(F("[DALI] TX pin allocation failed")); - PinManager::deallocatePin(_rxPin, PinOwner::UM_DALI_GEAR); - _enabled = false; - _initDone = true; - return; - } + if (!PinManager::allocatePin(_rxPin, false, PinOwner::UM_DALI_GEAR)) { + DEBUG_PRINTLN(F("[DALI] RX pin allocation failed")); + _enabled = false; + _initDone = true; + return; + } + if (!PinManager::allocatePin(_txPin, true, PinOwner::UM_DALI_GEAR)) { + DEBUG_PRINTLN(F("[DALI] TX pin allocation failed")); + PinManager::deallocatePin(_rxPin, PinOwner::UM_DALI_GEAR); + _enabled = false; + _initDone = true; + return; + } // Configure GPIO - pinMode(_rxPin, INPUT); - pinMode(_txPin, OUTPUT); + pinMode(_rxPin, INPUT); + pinMode(_txPin, OUTPUT); // Idle state: bus not asserted. Polarity depends on interface circuit. - digitalWrite(_txPin, _txInverted ? LOW : HIGH); + digitalWrite(_txPin, _txInverted ? LOW : HIGH); // Store static copies for HAL callbacks - _rxPinStatic = _rxPin; - _txPinStatic = _txPin; - _txInvertedStatic = _txInverted; - - _dali.begin(busIsHigh, busSetLow, busSetHigh); - - // Hardware timer: IDF v4 API - // Timer 1 (timer 0 is used by SparkFunDMX), prescaler 80 → 1 MHz tick. - // Alarm at 104 ticks → ~9615 Hz ≈ 1200 baud × 8 oversample. - _daliTimer = timerBegin(1, 80, true); - timerAttachInterrupt(_daliTimer, &daliTimerISR, true); - timerAlarmWrite(_daliTimer, 104, true); - timerAlarmEnable(_daliTimer); - - DEBUG_PRINTF("[DALI] Gear usermod initialised (RX=%d TX=%d txInv=%d addr=%d)\n", - _rxPin, _txPin, (int)_txInverted, _daliAddr); - _initDone = true; - } + _rxPinStatic = _rxPin; + _txPinStatic = _txPin; + _txInvertedStatic = _txInverted; + + _dali.begin(busIsHigh, busSetLow, busSetHigh); + + // Hardware timer: arduino-esp32 core 3.x (IDF v5) API. + // timerBegin() takes the tick frequency in Hz directly (no divider/edge args). + // 1 MHz tick, alarm at 104 ticks → ~9615 Hz ≈ 1200 baud × 8 oversample. + _daliTimer = timerBegin(1000000); + timerAttachInterrupt(_daliTimer, &daliTimerISR); + timerAlarm(_daliTimer, 104, true, 0); + + DEBUG_PRINTF("[DALI] Gear usermod initialised (RX=%d TX=%d txInv=%d addr=%d)\n", _rxPin, _txPin, (int)_txInverted, _daliAddr); + _initDone = true; + } - void loop() override { - if (!_enabled || !_initDone || _rxPin < 0) return; + void loop() override { + if (!_enabled || !_initDone || _rxPin < 0) return; // Send any pending backward frame once the settling window opens (≥7Te ≈ 2.9ms). - if (_pendingBF && (millis() >= _pendingBFTime)) { - uint8_t bf = _pendingBF; - _pendingBF = 0; - uint8_t result = _dali.tx(&bf, 8); - DEBUG_PRINTF("[DALI] backward frame 0x%02x sent (tx result=%u)\n", bf, result); - } + if (_pendingBF && (millis() >= _pendingBFTime)) { + uint8_t bf = _pendingBF; + _pendingBF = 0; + uint8_t result = _dali.tx(&bf, 8); + DEBUG_PRINTF("[DALI] backward frame 0x%02x sent (tx result=%u)\n", bf, result); + } - uint8_t data[4]; - uint8_t bits = _dali.rx(data); + uint8_t data[4]; + uint8_t bits = _dali.rx(data); - if (bits == 0) return; // nothing received + if (bits == 0) return; // nothing received // A DALI forward frame is exactly 16 bits (2 bytes). // 1-bit returns are normal bus-idle sampling noise from the library — discard silently. // Log only genuinely unexpected lengths (partial frames: 3–15 bits). - if (bits != 16) { - if (bits > 2) { - DEBUG_PRINTF("[DALI] partial frame: %u bits (data: 0x%02x 0x%02x 0x%02x 0x%02x)\n", - bits, data[0], data[1], data[2], data[3]); - } - return; + if (bits != 16) { + if (bits > 2) { + DEBUG_PRINTF("[DALI] partial frame: %u bits (data: 0x%02x 0x%02x 0x%02x 0x%02x)\n", bits, data[0], data[1], data[2], data[3]); } + return; + } - uint8_t addrByte = data[0]; - uint8_t cmdByte = data[1]; + uint8_t addrByte = data[0]; + uint8_t cmdByte = data[1]; - DEBUG_PRINTF("[DALI] raw frame: addr=0x%02x cmd=0x%02x\n", addrByte, cmdByte); + DEBUG_PRINTF("[DALI] raw frame: addr=0x%02x cmd=0x%02x\n", addrByte, cmdByte); // Sniff special broadcast commands that are NOT gear-addressed. // These must be processed regardless of our _daliAddr setting. // 0xA3 xx — SET DTR0 (Data Transfer Register 0) = xx // 0xC3 xx — SET DTR1 (Data Transfer Register 1) = xx // 0xC1 08 — ENABLE DEVICE TYPE 8 - if (addrByte == 0xA3) { - _dtr0 = cmdByte; - DEBUG_PRINTF("[DALI] SET DTR0 = 0x%02x (%u)\n", cmdByte, cmdByte); - return; - } - if (addrByte == 0xC3) { - _dtr1 = cmdByte; - DEBUG_PRINTF("[DALI] SET DTR1 = 0x%02x (%u)\n", cmdByte, cmdByte); - return; - } - if (addrByte == 0xC1) { - if (cmdByte == 8) { - _dt8Active = true; - DEBUG_PRINTLN(F("[DALI] ENABLE DEVICE TYPE 8")); - } else { - DEBUG_PRINTF("[DALI] ENABLE DEVICE TYPE %u (not handled)\n", cmdByte); - } - return; + if (addrByte == 0xA3) { + _dtr0 = cmdByte; + DEBUG_PRINTF("[DALI] SET DTR0 = 0x%02x (%u)\n", cmdByte, cmdByte); + return; + } + if (addrByte == 0xC3) { + _dtr1 = cmdByte; + DEBUG_PRINTF("[DALI] SET DTR1 = 0x%02x (%u)\n", cmdByte, cmdByte); + return; + } + if (addrByte == 0xC1) { + if (cmdByte == 8) { + _dt8Active = true; + DEBUG_PRINTLN(F("[DALI] ENABLE DEVICE TYPE 8")); + } else { + DEBUG_PRINTF("[DALI] ENABLE DEVICE TYPE %u (not handled)\n", cmdByte); } + return; + } - if (!daliAddressedToMe(addrByte, _daliAddr)) { - DEBUG_PRINTF("[DALI] frame not for us: addr=0x%02x (our addr=%d) — ignored\n", - addrByte, _daliAddr); - return; - } + if (!daliAddressedToMe(addrByte, _daliAddr)) { + DEBUG_PRINTF("[DALI] frame not for us: addr=0x%02x (our addr=%d) — ignored\n", addrByte, _daliAddr); + return; + } - bool isDapc = !(addrByte & 0x01); // S bit = 0 → DAPC + bool isDapc = !(addrByte & 0x01); // S bit = 0 → DAPC - if (isDapc) { - if (cmdByte == 255) { - DEBUG_PRINTLN(F("[DALI] DAPC 255 (mask) — ignored")); - } else { - DEBUG_PRINTF("[DALI] DAPC level=%u → bri=%u\n", cmdByte, daliLevelToWledBri(cmdByte)); - applyLevel(cmdByte); + if (isDapc) { + if (cmdByte == 255) { + DEBUG_PRINTLN(F("[DALI] DAPC 255 (mask) — ignored")); + } else { + DEBUG_PRINTF("[DALI] DAPC level=%u → bri=%u\n", cmdByte, daliLevelToWledBri(cmdByte)); + applyLevel(cmdByte); // Some masters use a non-standard combined flow: DTR0/DTR1 set the colour // temperature, ENABLE DEVICE TYPE 8 arms it, and the subsequent DAPC applies // both brightness and CCT in one go (without 0xE1+0xE2). - if (_dt8Active && (_dtr1 || _dtr0)) { - uint16_t mireds = ((uint16_t)_dtr1 << 8) | _dtr0; - applyCCT(mireds); - } - _dt8Active = false; + if (_dt8Active && (_dtr1 || _dtr0)) { + uint16_t mireds = ((uint16_t)_dtr1 << 8) | _dtr0; + applyCCT(mireds); } - } else { - DEBUG_PRINTF("[DALI] command 0x%02x (%u)\n", cmdByte, cmdByte); - handleCommand(cmdByte); + _dt8Active = false; } + } else { + DEBUG_PRINTF("[DALI] command 0x%02x (%u)\n", cmdByte, cmdByte); + handleCommand(cmdByte); } + } - void addToJsonInfo(JsonObject& root) override { - if (!_initDone) return; - JsonObject user = root["u"]; - if (user.isNull()) user = root.createNestedObject("u"); + void addToJsonInfo(JsonObject& root) override { + if (!_initDone) return; + JsonObject user = root["u"]; + if (user.isNull()) user = root.createNestedObject("u"); - JsonArray arr = user.createNestedArray(FPSTR(_name)); - if (!_enabled) { - arr.add(F("disabled")); - return; - } - if (_rxPin < 0 || _txPin < 0) { - arr.add(F("pins not configured")); - return; - } - arr.add(_lastDaliLevel); - arr.add(F(" DALI level")); - if (_lastCCTKelvin > 0) { - JsonArray cctArr = user.createNestedArray(F("DALIGear CCT")); - cctArr.add(_lastCCTKelvin); - cctArr.add(F(" K")); - } + JsonArray arr = user.createNestedArray(FPSTR(_name)); + if (!_enabled) { + arr.add(F("disabled")); + return; } + if (_rxPin < 0 || _txPin < 0) { + arr.add(F("pins not configured")); + return; + } + arr.add(_lastDaliLevel); + arr.add(F(" DALI level")); + if (_lastCCTKelvin > 0) { + JsonArray cctArr = user.createNestedArray(F("DALIGear CCT")); + cctArr.add(_lastCCTKelvin); + cctArr.add(F(" K")); + } + } - void addToConfig(JsonObject& root) override { - JsonObject top = root.createNestedObject(FPSTR(_name)); - top[FPSTR(_enabled_key)] = _enabled; - top["pin_rx"] = _rxPin; - top["pin_tx"] = _txPin; - top["tx_inverted"] = _txInverted; - top["daliAddr"] = _daliAddr; - } + void addToConfig(JsonObject& root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top[FPSTR(_enabled_key)] = _enabled; + top["pin_rx"] = _rxPin; + top["pin_tx"] = _txPin; + top["tx_inverted"] = _txInverted; + top["daliAddr"] = _daliAddr; + } - bool readFromConfig(JsonObject& root) override { - JsonObject top = root[FPSTR(_name)]; - bool configComplete = !top.isNull(); + bool readFromConfig(JsonObject& root) override { + JsonObject top = root[FPSTR(_name)]; + bool configComplete = !top.isNull(); - configComplete &= getJsonValue(top[FPSTR(_enabled_key)], _enabled, false); - configComplete &= getJsonValue(top["pin_rx"], _rxPin, (int8_t)14); - configComplete &= getJsonValue(top["pin_tx"], _txPin, (int8_t)17); - configComplete &= getJsonValue(top["tx_inverted"], _txInverted, false); - configComplete &= getJsonValue(top["daliAddr"], _daliAddr, (int8_t)-1); + configComplete &= getJsonValue(top[FPSTR(_enabled_key)], _enabled, false); + configComplete &= getJsonValue(top["pin_rx"], _rxPin, (int8_t)14); + configComplete &= getJsonValue(top["pin_tx"], _txPin, (int8_t)17); + configComplete &= getJsonValue(top["tx_inverted"], _txInverted, false); + configComplete &= getJsonValue(top["daliAddr"], _daliAddr, (int8_t)-1); - return configComplete; - } + return configComplete; + } - void appendConfigData() override { - oappend(F("addInfo('")); - oappend(String(FPSTR(_name)).c_str()); - oappend(F(":pin_rx',1,'DALI RX pin');")); - oappend(F("addInfo('")); - oappend(String(FPSTR(_name)).c_str()); - oappend(F(":pin_tx',1,'DALI TX pin');")); - oappend(F("addInfo('")); - oappend(String(FPSTR(_name)).c_str()); - oappend(F(":tx_inverted',1,'Invert TX — enable for single-stage inverting circuits (e.g. DIY PNP). Leave off for Waveshare Pico-DALI2 and NPN+opto boards.');")); - oappend(F("addInfo('")); - oappend(String(FPSTR(_name)).c_str()); - oappend(F(":daliAddr',1,'Short address (0\u201363) or -1 for broadcast only');")); - } + void appendConfigData() override { + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); + oappend(F(":pin_rx',1,'DALI RX pin');")); + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); + oappend(F(":pin_tx',1,'DALI TX pin');")); + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); + oappend(F(":tx_inverted',1,'Invert TX — enable for single-stage inverting circuits (e.g. DIY PNP). Leave off for Waveshare Pico-DALI2 and NPN+opto boards.');")); + oappend(F("addInfo('")); + oappend(String(FPSTR(_name)).c_str()); + oappend(F(":daliAddr',1,'Short address (0\u201363) or -1 for broadcast only');")); + } - uint16_t getId() override { return USERMOD_ID_DALI_GEAR; } + uint16_t getId() override { return USERMOD_ID_DALI_GEAR; } }; // Static member definitions -int8_t DaliGearUsermod::_rxPinStatic = -1; -int8_t DaliGearUsermod::_txPinStatic = -1; -bool DaliGearUsermod::_txInvertedStatic = false; +int8_t DaliGearUsermod::_rxPinStatic = -1; +int8_t DaliGearUsermod::_txPinStatic = -1; +bool DaliGearUsermod::_txInvertedStatic = false; -const char DaliGearUsermod::_name[] PROGMEM = "DALIGear"; +const char DaliGearUsermod::_name[] PROGMEM = "DALIGear"; const char DaliGearUsermod::_enabled_key[] PROGMEM = "enabled"; static DaliGearUsermod dali_gear_usermod; From cfd6cec4f0f4dcb01d0ed737c6b55a1b77ff341c Mon Sep 17 00:00:00 2001 From: Will Tatam Date: Sat, 12 Sep 2026 13:47:58 +0100 Subject: [PATCH 7/7] fix(dali_gear): support both arduino-esp32 core 2.x and 3.x timer APIs The combined 'usermods' CI build environment (env:usermods) still extends env:esp32dev_V4, which uses the older arduino-esp32 core 2.x (IDF v4) timer API, while the per-usermod envs (usermods_esp32/esp32s2/esp32s3/esp32c3) use core 3.x (IDF v5). Gate the timer setup on ESP_ARDUINO_VERSION_MAJOR so both build targets compile. --- usermods/dali_gear/usermod_dali_gear.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/usermods/dali_gear/usermod_dali_gear.cpp b/usermods/dali_gear/usermod_dali_gear.cpp index cb0b3461c6..097fbac928 100644 --- a/usermods/dali_gear/usermod_dali_gear.cpp +++ b/usermods/dali_gear/usermod_dali_gear.cpp @@ -332,12 +332,25 @@ class DaliGearUsermod : public Usermod { _dali.begin(busIsHigh, busSetLow, busSetHigh); - // Hardware timer: arduino-esp32 core 3.x (IDF v5) API. - // timerBegin() takes the tick frequency in Hz directly (no divider/edge args). - // 1 MHz tick, alarm at 104 ticks → ~9615 Hz ≈ 1200 baud × 8 oversample. + // Hardware timer: 1 MHz tick, alarm at 104 ticks → ~9615 Hz ≈ 1200 baud × 8 oversample. + // API differs between arduino-esp32 core generations: + // - core 3.x (IDF v5): timerBegin() takes the tick frequency in Hz directly, + // timerAttachInterrupt() has no edge arg, and timerAlarm() combines + // write+enable in one call. + // - core 2.x (IDF v4): timerBegin() takes (timer, prescaler, countUp), + // timerAttachInterrupt() takes an edge arg, and the alarm is armed via + // separate timerAlarmWrite()/timerAlarmEnable() calls. +#if ESP_ARDUINO_VERSION_MAJOR >= 3 _daliTimer = timerBegin(1000000); timerAttachInterrupt(_daliTimer, &daliTimerISR); timerAlarm(_daliTimer, 104, true, 0); +#else + // Timer 1 (timer 0 is used by SparkFunDMX), prescaler 80 → 1 MHz tick. + _daliTimer = timerBegin(1, 80, true); + timerAttachInterrupt(_daliTimer, &daliTimerISR, true); + timerAlarmWrite(_daliTimer, 104, true); + timerAlarmEnable(_daliTimer); +#endif DEBUG_PRINTF("[DALI] Gear usermod initialised (RX=%d TX=%d txInv=%d addr=%d)\n", _rxPin, _txPin, (int)_txInverted, _daliAddr); _initDone = true;