From bdd2e9d3639c3a35c1fa47396953bd0ec6174cd6 Mon Sep 17 00:00:00 2001 From: James Connolly Date: Tue, 23 Dec 2025 06:33:00 -0500 Subject: [PATCH 1/4] Enable ramp rates/transitions, fixed ramp events to synchronize UI w/ ramp progress, enabled color-on-previous, enabled dim-to-warm, added full ALS Support, ALS scene tracking, daylight/color preset IDs and more --- CHANGELOG-v107.md | 271 +++++++++++++++ Control4-HA-Base | 2 +- commands.lua | 853 +++++++++++++++++++++++++++++++++++++++++----- driver.xml | 13 +- manifest.xml | 17 + 5 files changed, 1069 insertions(+), 87 deletions(-) create mode 100644 CHANGELOG-v107.md create mode 100644 manifest.xml diff --git a/CHANGELOG-v107.md b/CHANGELOG-v107.md new file mode 100644 index 0000000..3266619 --- /dev/null +++ b/CHANGELOG-v107.md @@ -0,0 +1,271 @@ +# HA Light Driver v107 - Technical Documentation + +## Summary + +### New Features +- **Color On Mode Previous** - Enables the "Previous" color restore option in Composer Pro +- **Color On Mode Fade (Dim-to-Warm)** - Linear color interpolation between dim and bright colors based on brightness level +- **Configurable Color Trace Tolerance** - Adjustable Delta E tolerance for scene color matching + +### Fixes +- **Transition Rate Handling** - Brightness and color ramp times now properly respected +- **Advanced Lighting Scenes (ALS)** - Full `advanced_scene_support` API implementation +- **Scene Color Tracking** - Scenes now correctly mark as "Active" when color matches within tolerance + +### Improvements +- **Preset ID Support** - Daylight Agent preset tracking for brightness commands +- **Combined Scene Commands** - Brightness and color sent together to prevent visual artifacts +- **Ramp Timer Management** - Deferred state notifications during transitions for accurate scene tracking + +--- + +## Technical Details + +### 1. Color On Mode Capabilities + +Two new capabilities were added to `driver.xml`: + +```xml + + True + True + +``` + +**`color_on_mode_previous`**: Enables the "Previous" option in Composer Pro's Color On Mode settings. The proxy automatically tracks the last reported color before brightness goes to 0 and restores it on the next turn-on command. No driver-side logic is required beyond declaring the capability. + +**`color_on_mode_fade`**: Enables dim-to-warm behavior where the driver calculates an interpolated color based on brightness level. + +### 2. Dim-to-Warm Implementation + +When the dealer configures "Fade" mode in Composer Pro, the proxy sends `UPDATE_COLOR_ON_MODE` with two color presets: +- **On color** - The target color at 100% brightness +- **Dim color** - The target color at 1% brightness + +The driver stores these values: + +```lua +COLOR_ON_X, COLOR_ON_Y -- On color (100%) +COLOR_FADE_X, COLOR_FADE_Y -- Dim color (1%) +COLOR_ON_MODE_FADE_ENABLED -- True when both presets are defined +``` + +The interpolation formula in `SET_BRIGHTNESS_TARGET`: + +```lua +fadeX = COLOR_FADE_X + (COLOR_ON_X - COLOR_FADE_X) * brightness * 0.01 +fadeY = COLOR_FADE_Y + (COLOR_ON_Y - COLOR_FADE_Y) * brightness * 0.01 +``` + +The calculated color is sent alongside brightness in a single Home Assistant service call: + +```lua +brightnessServiceCall.service_data.color_temp_kelvin = C4:ColorXYtoCCT(fadeX, fadeY) +``` + +### 3. Suppressing Unwanted Color Commands in Fade Mode + +**Discovery**: When fade mode is active, the C4 proxy periodically sends `SET_COLOR_TARGET` commands with the preset On or Dim color values. These commands would override the driver's calculated fade color, causing the light to jump to the preset color instead of the interpolated color. + +**Solution**: The driver compares incoming `SET_COLOR_TARGET` coordinates against the stored preset colors and ignores commands that match: + +```lua +if COLOR_ON_MODE_FADE_ENABLED then + -- Check if target matches "On" color + if COLOR_ON_X and COLOR_ON_Y then + local dx = math.abs(targetX - COLOR_ON_X) + local dy = math.abs(targetY - COLOR_ON_Y) + if dx < 0.005 and dy < 0.005 then + return -- Ignore this command + end + end + + -- Check if target matches "Dim" color + if COLOR_FADE_X and COLOR_FADE_Y then + local dx = math.abs(targetX - COLOR_FADE_X) + local dy = math.abs(targetY - COLOR_FADE_Y) + if dx < 0.005 and dy < 0.005 then + return -- Ignore this command + end + end +end +``` + +The tolerance of 0.005 in XY space accounts for floating-point rounding. Commands with different colors (e.g., scene activations, manual color changes) pass through normally. + +**Limitation**: If the Composer UI has other color presets configured that don't match the current On/Dim presets, those sync commands will still reach the driver. This appears to be a Composer configuration issue rather than a driver issue. + +### 4. Ramp Timer Management + +**Discovery**: Home Assistant reports the target state immediately when a transition command is sent, rather than waiting for the transition to complete. If the driver forwards this state to C4 immediately, C4's scene tracking compares the target against the current (mid-transition) state and may incorrectly mark the scene as inactive. + +**Solution**: When a brightness or color command includes a rate > 0, the driver: +1. Sets a timer for the duration of the ramp +2. Defers `LIGHT_BRIGHTNESS_CHANGED` and `LIGHT_COLOR_CHANGED` notifications until the timer expires +3. Stores pending state data if HA reports during the ramp + +```lua +if rate > 0 then + BRIGHTNESS_RAMP_PENDING = false + BRIGHTNESS_RAMP_TIMER = C4:SetTimer(rate, function(timer) + BRIGHTNESS_RAMP_TIMER = nil + if BRIGHTNESS_RAMP_PENDING then + BRIGHTNESS_RAMP_PENDING = false + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', ...) + end + end) +end +``` + +In the `Parse()` function that handles HA state updates: + +```lua +if BRIGHTNESS_RAMP_TIMER then + BRIGHTNESS_RAMP_PENDING = true -- Defer notification +else + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', ...) -- Immediate +end +``` + +### 5. Combined Scene Commands + +**Discovery**: When `ACTIVATE_SCENE` includes both brightness and color, the driver originally executed them as separate HA commands. With dim-to-warm enabled, this caused a visual flash: +1. `SET_BRIGHTNESS_TARGET` applied the dim-to-warm interpolated color +2. Milliseconds later, `SET_COLOR_TARGET` applied the scene's actual color + +**Solution**: When a scene has both brightness and color enabled, send them as a single HA service call: + +```lua +if (levelEnabled or el.level ~= nil or el.brightness ~= nil) and colorEnabled then + local sceneServiceCall = { + domain = "light", + service = "turn_on", + service_data = { + brightness = targetMappedValue, + color_temp_kelvin = kelvin, -- or xy_color + transition = maxRate / 1000 + }, + target = { entity_id = EntityID } + } + C4:SendToProxy(999, "HA_CALL_SERVICE", { JSON = JSON:encode(sceneServiceCall) }) + return -- Skip individual brightness/color handling +end +``` + +### 6. C4 Color Conversion Functions + +**Critical**: Always use C4's native color conversion functions to stay within C4's color space. Using external formulas produces different XY values that break scene tracking. + +Available functions: +- `C4:ColorCCTtoXY(kelvin)` - Kelvin to CIE 1931 xy +- `C4:ColorXYtoCCT(x, y)` - xy to Kelvin +- `C4:ColorHSVtoXY(h, s, v)` - HSV to xy +- `C4:ColorXYtoHSV(x, y)` - xy to HSV +- `C4:ColorRGBtoXY(r, g, b)` - RGB to xy +- `C4:ColorXYtoRGB(x, y)` - xy to RGB + +The round-trip through C4's conversion ensures matching XY coordinates: +1. Receive XY from C4 scene +2. Convert to Kelvin: `C4:ColorXYtoCCT(x, y)` +3. Send to HA as `color_temp_kelvin` +4. Receive `color_temp_kelvin` from HA +5. Convert back: `C4:ColorCCTtoXY(kelvin)` +6. Report to C4 with `LIGHT_COLOR_CHANGED` + +### 7. Color Trace Tolerance + +The `color_trace_tolerance` capability controls scene color matching precision. Exposed as a configurable property in Composer Pro (range 0.5 to 10.0, default 1.0). + +```lua +function OPC.Color_Trace_Tolerance(value) + COLOR_TRACE_TOLERANCE = tonumber(value) or 1.0 + C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', { + color_trace_tolerance = COLOR_TRACE_TOLERANCE + }, "NOTIFY") +end +``` + +Comparison methods (handled by C4 Director): +- Delta > 0.01: Uses CIE L*a*b* Delta E formula +- Delta <= 0.01: Uses xy chromaticity Euclidean distance + +Most humans detect color differences at Delta E >= 3.0. + +### 8. Preset ID Support + +For Daylight Agent integration, the driver tracks preset IDs from `SET_BRIGHTNESS_TARGET`: + +```lua +LIGHT_BRIGHTNESS_PRESET_ID = tonumber(tParams.LIGHT_BRIGHTNESS_TARGET_PRESET_ID) +LIGHT_BRIGHTNESS_PRESET_LEVEL = target + +function BuildBrightnessChangedParams(level) + local params = { LIGHT_BRIGHTNESS_CURRENT = level } + if LIGHT_BRIGHTNESS_PRESET_ID and LIGHT_BRIGHTNESS_PRESET_LEVEL == level then + params.LIGHT_BRIGHTNESS_CURRENT_PRESET_ID = LIGHT_BRIGHTNESS_PRESET_ID + end + return params +end +``` + +The preset ID is included in `LIGHT_BRIGHTNESS_CHANGED` only when the reported level matches the preset's target level. + +### 9. Advanced Lighting Scenes (ALS) Implementation + +The driver declares `advanced_scene_support` capability in `driver.xml`: + +```xml + + True + +``` + +This capability requires implementing the following commands: + +| Command | Status | Description | +|---------|--------|-------------| +| `PUSH_SCENE` | ✓ Implemented | Stores scene XML data for later activation | +| `ACTIVATE_SCENE` | ✓ Implemented | Executes a stored scene with brightness, color, and rates | +| `RAMP_SCENE_UP` | ✓ Implemented | Ramps to scene's target level (HA lacks continuous ramping) | +| `RAMP_SCENE_DOWN` | ✓ Implemented | Ramps to 0 (HA lacks continuous ramping) | +| `STOP_SCENE_RAMP` | ✓ Implemented | Freezes at current level by sending transition=0 | +| `SYNC_SCENE` | Not needed | Legacy command (pre-3.0.0), handled by PUSH_SCENE | +| `SYNC_ALL_SCENES` | Not needed | Legacy command (pre-3.0.0), handled by PUSH_SCENE | + +**PUSH_SCENE**: Parses the scene XML and stores it via `C4:PersistSetValue()`. Scene elements include: +- `level`/`brightness` - Target brightness (0-100) +- `levelEnabled`/`brightnessEnabled` - Whether brightness is part of scene +- `rate`/`brightnessRate` - Transition time in milliseconds +- `colorX`, `colorY` - CIE 1931 xy coordinates +- `colorMode` - 0 (full color) or 1 (CCT) +- `colorEnabled` - Whether color is part of scene +- `colorRate` - Color transition time in milliseconds + +**ACTIVATE_SCENE**: Retrieves stored scene data and executes it. When both brightness and color are enabled, they are sent as a single HA command to prevent race conditions with dim-to-warm. + +**RAMP_SCENE_UP/DOWN**: Since Home Assistant doesn't support continuous ramping (press-and-hold behavior), we implement these by: +- UP: Ramp to the scene's target brightness level +- DOWN: Ramp to 0 + +**STOP_SCENE_RAMP**: Sends the current brightness level to HA with `transition=0` to freeze at the current position. + +**Note on SYNC_SCENE/SYNC_ALL_SCENES**: Per C4 documentation, these are legacy commands used as workarounds pre-3.0.0. They are not needed if `PUSH_SCENE` is properly handled, which it is. + +**Performance requirement**: The driver uses the Brightness Target API (`LIGHT_BRIGHTNESS_CHANGING`/`LIGHT_BRIGHTNESS_CHANGED`) and only sends one level update when the hardware reaches the final scene level. This is achieved through ramp timer management that defers `CHANGED` notifications until the transition completes. + +--- + +## Files Modified + +- `driver.xml` - Added `color_on_mode_previous` and `color_on_mode_fade` capabilities +- `commands.lua` - All handler implementations + +## Testing Notes + +1. Enable Debug Mode in Composer Pro to see detailed logging +2. Test dim-to-warm by adjusting brightness and observing color temperature +3. Test scene activation with both brightness and color enabled +4. Verify scene shows "Active" status after activation +5. Test transition rates by observing ramp timing matches configured values +6. Test scene ramp up/down by holding buttons in Navigator (if keypads support it) +7. Verify PUSH_SCENE stores data correctly (check debug output on driver load) diff --git a/Control4-HA-Base b/Control4-HA-Base index 4959f51..1e01f3f 160000 --- a/Control4-HA-Base +++ b/Control4-HA-Base @@ -1 +1 @@ -Subproject commit 4959f5180dd79499ca14efba470318a5b27882c6 +Subproject commit 1e01f3fec9cb0cb123ec5f7942b9a7c9d1cad43d diff --git a/commands.lua b/commands.lua index bbfe3cc..3448da6 100644 --- a/commands.lua +++ b/commands.lua @@ -1,3 +1,20 @@ +--[[============================================================================= + HA Light Driver - Proxy Command Handlers + + Handles Light V2 proxy commands from C4 Director and translates them to + Home Assistant service calls via the HA_CALL_SERVICE binding (999). + + Key concepts: + - RFP.* functions handle commands FROM the C4 proxy + - OPC.* functions handle property changes from Composer + - C4:SendToProxy(5001, ...) sends notifications TO the C4 proxy + - C4:SendToProxy(999, "HA_CALL_SERVICE", ...) sends commands to Home Assistant +===============================================================================]] + + +require('Control4-HA-Base.helpers') + +-- Light capabilities (populated from HA state) SUPPORTED_ATTRIBUTES = {} MIN_K_TEMP = 500 MAX_K_TEMP = 20000 @@ -5,12 +22,81 @@ HAS_BRIGHTNESS = true HAS_EFFECTS = false LAST_EFFECT = "Select Effect" EFFECTS_LIST = {} + +-- Current state tracking WAS_ON = false +LIGHT_LEVEL = 0 -- Current brightness (0-100) + +-- Daylight Agent preset tracking +LIGHT_BRIGHTNESS_PRESET_ID = nil +LIGHT_BRIGHTNESS_PRESET_LEVEL = nil + +-- Ramp timer state: HA reports target state immediately during transitions, +-- but C4 expects CHANGED notifications only after the ramp completes. +-- We defer notifications until the timer expires to ensure accurate scene tracking. +BRIGHTNESS_RAMP_TIMER = nil +BRIGHTNESS_RAMP_PENDING = false +COLOR_RAMP_TIMER = nil +COLOR_RAMP_PENDING = false +COLOR_RAMP_PENDING_DATA = nil + +-- Dim-to-Warm (Color Fade Mode): When enabled, color interpolates linearly +-- between Dim color (at 1%) and On color (at 100%) based on brightness. +-- These values are set by UPDATE_COLOR_ON_MODE from the proxy. +COLOR_ON_MODE_FADE_ENABLED = false +COLOR_ON_X = nil +COLOR_ON_Y = nil +COLOR_ON_MODE = nil +COLOR_FADE_X = nil +COLOR_FADE_Y = nil +COLOR_FADE_MODE = nil + +--[[=========================================================================== + Driver Load Functions + Scene color matching tolerance (Delta E in CIE L*a*b* space) +===========================================================================]] + +function OnDriverInit() + -- 1. Read Static Capabilities (Hardware definitions from XML) + local color_tolerance = C4:GetCapability("color_trace_tolerance") + if color_tolerance ~= nil then + local parsed = tonumber(color_tolerance) + + if parsed then + COLOR_TRACE_TOLERANCE = parsed + print("Driver Init: COLOR_TRACE_TOLERANCE set to " .. COLOR_TRACE_TOLERANCE) + else + -- Explicit failure logging + print("[ERROR] Driver XML Capability 'color_trace_tolerance' defined but invalid: '" .. tostring(color_tolerance) .. "'. Expected a number.") + -- Optionally: leave COLOR_TOLERANCE as nil to force a crash later if that's preferred. + end + else + print("[WARNING] Driver XML Capability 'color_trace_tolerance' is missing.") + end +end + +--[[=========================================================================== + Helper Functions +===========================================================================]] +function BuildBrightnessChangedParams(level) + local params = { LIGHT_BRIGHTNESS_CURRENT = level } + -- Include preset ID if level matches the preset target + if LIGHT_BRIGHTNESS_PRESET_ID and LIGHT_BRIGHTNESS_PRESET_LEVEL == level then + params.LIGHT_BRIGHTNESS_CURRENT_PRESET_ID = LIGHT_BRIGHTNESS_PRESET_ID + end + return params +end +--[[=========================================================================== + Proxy Command Handlers (RFP.*) +===========================================================================]] + +-- Called by Director to sync current state function RFP.SYNCHRONIZE(idBinding, strCommand, tParams) - C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', { LIGHT_BRIGHTNESS_CURRENT = LIGHT_LEVEL }) + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) end +-- Handle physical button presses (Top=On, Bottom=Off, Toggle) function RFP.BUTTON_ACTION(idBinding, strCommand, tParams) if tParams.ACTION == "2" then if tParams.BUTTON_ID == "0" then @@ -27,22 +113,341 @@ function RFP.BUTTON_ACTION(idBinding, strCommand, tParams) end end +--[[=========================================================================== + Advanced Lighting Scene (ALS) Handlers + + Required by advanced_scene_support capability. See driver.xml. + SYNC_SCENE and SYNC_ALL_SCENES are legacy (pre-3.0.0) and not needed + when PUSH_SCENE is properly implemented. +===========================================================================]] + +-- Store scene data from Director. Called when scenes are created/modified. +-- Scene data is persisted and retrieved by ACTIVATE_SCENE. function RFP.PUSH_SCENE(idBinding, strCommand, tParams) - local scene_value = C4:ParseXml(tParams.ELEMENTS) - C4:PersistSetValue("ALS:" .. tParams.SCENE_ID, scene_value, false) + -- Parse scene XML into simple key-value table + local xml = C4:ParseXml(tParams.ELEMENTS) + local element = {} + + if DEBUGPRINT then + print("[DEBUG ALS] PUSH_SCENE " .. tParams.SCENE_ID .. " raw XML name: " .. tostring(xml.Name)) + end + + -- Handle nested wrapper if present + local nodes = xml.ChildNodes + if xml.Name == "element" then + nodes = xml.ChildNodes + end + + for _, child in ipairs(nodes) do + local value = child.Value + -- Convert to appropriate type + if value == "True" or value == "true" then + value = true + elseif value == "False" or value == "false" then + value = false + else + value = tonumber(value) or value + end + element[child.Name] = value + end + + C4:PersistSetValue("ALS:" .. tParams.SCENE_ID, element, false) + + if DEBUGPRINT then + print("[DEBUG ALS] PUSH_SCENE " .. tParams.SCENE_ID .. " stored:") + for k, v in pairs(element) do + print("[DEBUG ALS] " .. k .. " = " .. tostring(v) .. " (" .. type(v) .. ")") + end + end end +-- Execute a previously stored scene. Retrieves scene data and sends to HA. function RFP.ACTIVATE_SCENE(idBinding, strCommand, tParams) - local scene_value = C4:PersistGetValue("ALS:" .. tParams.SCENE_ID, false) - print("Loading Advanced Lighting Scene " .. tParams.SCENE_ID) - for _, v in pairs(scene_value["ChildNodes"]) do - if v["Name"] == "level" or v["Name"] == "brightness" then - SetLightValue(v["Value"]) - break + local el = C4:PersistGetValue("ALS:" .. tParams.SCENE_ID, false) + + if el == nil then + print("No scene data for scene " .. tParams.SCENE_ID) + return + end + + if DEBUGPRINT then + print("[DEBUG ALS] ACTIVATE_SCENE " .. tParams.SCENE_ID .. ":") + for k, v in pairs(el) do + print("[DEBUG ALS] " .. k .. " = " .. tostring(v) .. " (" .. type(v) .. ")") end end + + local levelEnabled = (el.brightnessEnabled == true) or (el.levelEnabled == true) + local colorEnabled = (el.colorEnabled == true) and el.colorX ~= nil and el.colorY ~= nil + + if DEBUGPRINT then + print("[DEBUG ALS] levelEnabled=" .. tostring(levelEnabled) .. + " colorEnabled=" .. tostring(colorEnabled)) + end + + -- When scene has both brightness and color, send them together to avoid + -- dim-to-warm applying a conflicting color before the scene color arrives + if (levelEnabled or el.level ~= nil or el.brightness ~= nil) and colorEnabled then + local target = el.level or el.brightness or 0 + local rate = el.rate or el.brightnessRate or 0 + local colorRate = el.colorRate or 0 + + if DEBUGPRINT then + print("[DEBUG ALS] Scene has brightness AND color - sending combined command") + end + + -- Notify proxy that brightness is changing + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGING', { + LIGHT_BRIGHTNESS_CURRENT = LIGHT_LEVEL, + LIGHT_BRIGHTNESS_TARGET = target, + RATE = rate + }) + + -- Notify proxy that color is changing + C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGING', { + LIGHT_COLOR_TARGET_X = el.colorX, + LIGHT_COLOR_TARGET_Y = el.colorY, + LIGHT_COLOR_TARGET_COLOR_MODE = el.colorMode or 0, + LIGHT_COLOR_TARGET_COLOR_RATE = colorRate + }) + + -- Cancel any existing ramp timers + if BRIGHTNESS_RAMP_TIMER then + BRIGHTNESS_RAMP_TIMER:Cancel() + BRIGHTNESS_RAMP_TIMER = nil + end + if COLOR_RAMP_TIMER then + COLOR_RAMP_TIMER:Cancel() + COLOR_RAMP_TIMER = nil + end + + -- Set up ramp timer for brightness (use the longer of the two rates) + local maxRate = math.max(rate, colorRate) + if maxRate > 0 then + BRIGHTNESS_RAMP_PENDING = false + COLOR_RAMP_PENDING = false + COLOR_RAMP_PENDING_DATA = nil + BRIGHTNESS_RAMP_TIMER = C4:SetTimer(maxRate, function(timer) + BRIGHTNESS_RAMP_TIMER = nil + if BRIGHTNESS_RAMP_PENDING then + BRIGHTNESS_RAMP_PENDING = false + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) + end + if COLOR_RAMP_PENDING and COLOR_RAMP_PENDING_DATA then + COLOR_RAMP_PENDING = false + local data = COLOR_RAMP_PENDING_DATA + COLOR_RAMP_PENDING_DATA = nil + C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGED', { + LIGHT_COLOR_CURRENT_X = data.x, + LIGHT_COLOR_CURRENT_Y = data.y, + LIGHT_COLOR_CURRENT_COLOR_MODE = data.mode + }) + end + end) + end + + -- Build combined HA service call with brightness AND color + local targetMappedValue = MapValue(target, 255, 100) + local sceneServiceCall = { + domain = "light", + service = "turn_on", + service_data = { + brightness = targetMappedValue + }, + target = { + entity_id = EntityID + } + } + + -- Add transition time (use the longer rate) + if maxRate >= 0 then + sceneServiceCall.service_data.transition = maxRate / 1000 + end + + -- Add color (as CCT or XY depending on light capabilities) + local lightSupportsCCT = HasValue(SUPPORTED_ATTRIBUTES, "color_temp") + if lightSupportsCCT and (el.colorMode == 1 or el.colorMode == nil) then + local kelvin = C4:ColorXYtoCCT(el.colorX, el.colorY) + sceneServiceCall.service_data.color_temp_kelvin = kelvin + if DEBUGPRINT then + print("[DEBUG ALS] Combined: brightness=" .. target .. ", CCT=" .. kelvin .. "K") + end + else + sceneServiceCall.service_data.xy_color = { el.colorX, el.colorY } + if DEBUGPRINT then + print("[DEBUG ALS] Combined: brightness=" .. target .. ", XY=(" .. el.colorX .. "," .. el.colorY .. ")") + end + end + + -- Handle turn off + if target == 0 then + sceneServiceCall.service_data = { transition = sceneServiceCall.service_data.transition } + sceneServiceCall.service = "turn_off" + end + + C4:SendToProxy(999, "HA_CALL_SERVICE", { JSON = JSON:encode(sceneServiceCall) }) + return + end + + -- Execute brightness only (no color in scene) + if levelEnabled or (el.level ~= nil or el.brightness ~= nil) then + RFP.SET_BRIGHTNESS_TARGET(nil, nil, { + LIGHT_BRIGHTNESS_TARGET = el.level or el.brightness or 0, + RATE = el.rate or el.brightnessRate or 0 + }) + end + + -- Execute color only (no brightness in scene) + if colorEnabled then + RFP.SET_COLOR_TARGET(nil, nil, { + LIGHT_COLOR_TARGET_X = el.colorX, + LIGHT_COLOR_TARGET_Y = el.colorY, + LIGHT_COLOR_TARGET_MODE = el.colorMode or 0, + LIGHT_COLOR_TARGET_RATE = el.colorRate or 0 + }) + end +end + +-- Ramp scene up continuously (used when user holds button). +-- Since HA doesn't support continuous ramping, we ramp to the scene's target level. +function RFP.RAMP_SCENE_UP(idBinding, strCommand, tParams) + local sceneId = tParams.SCENE_ID + + if DEBUGPRINT then + print("[DEBUG ALS] RAMP_SCENE_UP: scene=" .. tostring(sceneId)) + end + + local rate = tonumber(tParams.RATE) or 0 + local el = C4:PersistGetValue("ALS:" .. sceneId, false) + if el == nil then + print("No scene data for scene " .. tostring(sceneId)) + return + end + + local target = el.level or el.brightness or 100 + SCENE_RAMP_START_TIME_MS = C4:GetTime() -- system time in ms. + SCENE_RAMP_DURATION_MS = rate + SCENE_RAMP_START_LEVEL = LIGHT_LEVEL -- Current level when ramp starts + SCENE_RAMP_TARGET_LEVEL = target + + if DEBUGPRINT then + print("rate = " .. tostring(rate)) + print("target = " .. tostring(target)) + print("SCENE_RAMP_START_TIME_MS = " .. tostring(SCENE_RAMP_START_TIME_MS)) + print("SCENE_RAMP_DURATION_MS = " .. tostring(SCENE_RAMP_DURATION_MS)) + print("SCENE_RAMP_START_LEVEL = " .. tostring(SCENE_RAMP_START_LEVEL)) + print("SCENE_RAMP_TARGET_LEVEL = " .. tostring(SCENE_RAMP_TARGET_LEVEL)) + end + + -- Ramp to scene's target brightness + RFP.SET_BRIGHTNESS_TARGET(nil, nil, { + LIGHT_BRIGHTNESS_TARGET = target, + RATE = rate + }) +end + +-- Ramp scene down continuously (used when user holds button). +-- Since HA doesn't support continuous ramping, we ramp to 0. +function RFP.RAMP_SCENE_DOWN(idBinding, strCommand, tParams) + local sceneId = tParams.SCENE_ID + local rate = tonumber(tParams.RATE) or 0 + + if DEBUGPRINT then + print("[DEBUG ALS] RAMP_SCENE_DOWN: scene=" .. tostring(sceneId) .. ", rate=" .. tostring(rate)) + end + + local el = C4:PersistGetValue("ALS:" .. sceneId, false) + local target = el.level or el.brightness or 100 + print("[DEBUG ALS] RAMP_SCENE_DOWN: scene=" .. tostring(sceneId) .. ", target=" .. tostring(target)) + SCENE_RAMP_START_TIME_MS = C4:GetTime() -- system time in ms. + SCENE_RAMP_DURATION_MS = rate + SCENE_RAMP_START_LEVEL = LIGHT_LEVEL -- Current level when ramp starts + SCENE_RAMP_TARGET_LEVEL = 0 + + -- Ramp to 0 + RFP.SET_BRIGHTNESS_TARGET(nil, nil, { + LIGHT_BRIGHTNESS_TARGET = 0, + RATE = rate + }) +end + +-- Stop an in-progress scene ramp (user released button). +-- Sends current level to HA to freeze at current position. +function RFP.STOP_SCENE_RAMP(idBinding, strCommand, tParams) + local elapsedTimeMs = C4:GetTime() - SCENE_RAMP_START_TIME_MS + local sceneId = tParams.SCENE_ID + + if DEBUGPRINT then + print("[DEBUG ALS] STOP_SCENE_RAMP: scene=" .. tostring(sceneId) .. ", freezing at level=" .. tostring(LIGHT_LEVEL)) + print("[DEBUG ALS] STOP_SCENE_RAMP: tParams:") + DumpTable(tParams) + end + + -- Cancel any pending ramp timer + if BRIGHTNESS_RAMP_TIMER then + BRIGHTNESS_RAMP_TIMER:Cancel() + BRIGHTNESS_RAMP_TIMER = nil + end + + newTargetLevel = Lerp(SCENE_RAMP_START_LEVEL, + SCENE_RAMP_TARGET_LEVEL, + elapsedTimeMs, + SCENE_RAMP_DURATION_MS) + + if DEBUGPRINT then + print("[DEBUG ALS] STOP_SCENE_RAMP: elapsedTimeMs=" .. tostring(elapsedTimeMs) .. ", newTargetLevel=" .. tostring(newTargetLevel)) + end + + + -- Send current level to HA to stop ramping at current position + local stopServiceCall = { + domain = "light", + service = "turn_on", + service_data = { + brightness = MapValue(newTargetLevel, 255, 100), + transition = 0 + }, + target = { entity_id = EntityID } + } + + + C4:SendToProxy(999, "HA_CALL_SERVICE", { JSON = JSON:encode(stopServiceCall) }) + + -- Notify proxy of current level + --C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) end +-- Receive color presets from proxy when Color On Mode is configured. +-- In "Fade" mode, stores On/Dim colors for dim-to-warm interpolation. +function RFP.UPDATE_COLOR_ON_MODE(idBinding, strCommand, tParams) + if DEBUGPRINT then + print("[DEBUG COLOR] UPDATE_COLOR_ON_MODE received:") + for k, v in pairs(tParams) do + print("[DEBUG COLOR] " .. tostring(k) .. " = " .. tostring(v)) + end + end + + -- Store the "On" color (at 100% brightness) + COLOR_ON_X = tonumber(tParams.COLOR_PRESET_COLOR_X) + COLOR_ON_Y = tonumber(tParams.COLOR_PRESET_COLOR_Y) + COLOR_ON_MODE = tonumber(tParams.COLOR_PRESET_COLOR_MODE) + + -- Store the "Dim" color (at 1% brightness) for fade/dim-to-warm mode + COLOR_FADE_X = tonumber(tParams.COLOR_FADE_PRESET_COLOR_X) + COLOR_FADE_Y = tonumber(tParams.COLOR_FADE_PRESET_COLOR_Y) + COLOR_FADE_MODE = tonumber(tParams.COLOR_FADE_PRESET_COLOR_MODE) + + -- Fade mode is enabled if we have both On and Dim colors defined + COLOR_ON_MODE_FADE_ENABLED = (COLOR_FADE_X ~= nil) and (COLOR_ON_X ~= nil) + + if DEBUGPRINT then + print("[DEBUG COLOR] UPDATE_COLOR_ON_MODE: fade_enabled=" .. tostring(COLOR_ON_MODE_FADE_ENABLED)) + print("[DEBUG COLOR] On color: X=" .. tostring(COLOR_ON_X) .. ", Y=" .. tostring(COLOR_ON_Y)) + print("[DEBUG COLOR] Dim color: X=" .. tostring(COLOR_FADE_X) .. ", Y=" .. tostring(COLOR_FADE_Y)) + end +end + +-- Simple on/off commands (no brightness/color specified) function RFP.ON(idBinding, strCommand, tParams) local turnOnServiceCall = { domain = "light", @@ -77,28 +482,15 @@ function RFP.OFF(idBinding, strCommand, tParams) C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) end -function RFP.DO_PUSH(idBinding, strCommand, tParams) - --Do nothing for now -end - -function RFP.DO_RELEASE(idBinding, strCommand, tParams) - --Do nothing for now -end +-- Button link handlers (bindings 200=Top, 201=Bottom, 202=Toggle) +function RFP.DO_PUSH(idBinding, strCommand, tParams) end +function RFP.DO_RELEASE(idBinding, strCommand, tParams) end function RFP.DO_CLICK(idBinding, strCommand, tParams) - local tParams = { - ACTION = "2", - BUTTON_ID = "" - } - - if idBinding == 200 then - tParams.BUTTON_ID = "0" - elseif idBinding == 201 then - tParams.BUTTON_ID = "1" - elseif idBinding == 202 then - tParams.BUTTON_ID = "2" - end - + local tParams = { ACTION = "2", BUTTON_ID = "" } + if idBinding == 200 then tParams.BUTTON_ID = "0" + elseif idBinding == 201 then tParams.BUTTON_ID = "1" + elseif idBinding == 202 then tParams.BUTTON_ID = "2" end RFP:BUTTON_ACTION(strCommand, tParams) end @@ -110,34 +502,219 @@ function SetLightValue(value) RFP.SET_BRIGHTNESS_TARGET(nil, nil, tParams) end +-- Set light color. Converts C4 XY coordinates to HA format (CCT or xy_color). +-- In fade mode, ignores commands matching On/Dim presets to prevent override. function RFP.SET_COLOR_TARGET(idBinding, strCommand, tParams) - local targetX = tParams.LIGHT_COLOR_TARGET_X - local targetY = tParams.LIGHT_COLOR_TARGET_Y + local targetX = tonumber(tParams.LIGHT_COLOR_TARGET_X) + local targetY = tonumber(tParams.LIGHT_COLOR_TARGET_Y) + local colorMode = tonumber(tParams.LIGHT_COLOR_TARGET_MODE) or 0 -- 0=Full Color, 1=CCT + + -- In fade mode, ignore SET_COLOR_TARGET if it matches the preset "On" or "Dim" color + -- The proxy sometimes sends these to "correct" the color, but we're handling + -- the fade color calculation ourselves in SET_BRIGHTNESS_TARGET + if COLOR_ON_MODE_FADE_ENABLED then + local ignored = false + local reason = nil + + -- Check if it matches the "On" color (100% brightness preset) + if COLOR_ON_X and COLOR_ON_Y then + local dx = math.abs(targetX - COLOR_ON_X) + local dy = math.abs(targetY - COLOR_ON_Y) + if dx < 0.005 and dy < 0.005 then + ignored = true + reason = "matches preset On color" + end + end + + -- Check if it matches the "Dim" color (1% brightness preset) + if not ignored and COLOR_FADE_X and COLOR_FADE_Y then + local dx = math.abs(targetX - COLOR_FADE_X) + local dy = math.abs(targetY - COLOR_FADE_Y) + if dx < 0.005 and dy < 0.005 then + ignored = true + reason = "matches preset Dim color" + end + end + + if ignored then + if DEBUGPRINT then + print("[DEBUG COLOR] SET_COLOR_TARGET ignored (fade mode, " .. reason .. ")") + print("[DEBUG COLOR] Target: X=" .. tostring(targetX) .. ", Y=" .. tostring(targetY)) + end + return + end + end + + local rate = tonumber(tParams.LIGHT_COLOR_TARGET_RATE) or 0 -- Rate in milliseconds (renamed from RATE in 3.3.2) + + -- Determine what color modes the light supports + local lightSupportsCCT = HasValue(SUPPORTED_ATTRIBUTES, "color_temp") + local lightSupportsFullColor = HasValue(SUPPORTED_ATTRIBUTES, "hs") or + HasValue(SUPPORTED_ATTRIBUTES, "xy") or + HasValue(SUPPORTED_ATTRIBUTES, "rgb") or + HasValue(SUPPORTED_ATTRIBUTES, "rgbw") or + HasValue(SUPPORTED_ATTRIBUTES, "rgbww") + + -- DEBUG: Log incoming parameters + if DEBUGPRINT then + print("[DEBUG COLOR] SET_COLOR_TARGET: X=" .. tostring(targetX) .. ", Y=" .. tostring(targetY) .. ", mode=" .. tostring(colorMode) .. ", rate=" .. tostring(rate) .. "ms") + print("[DEBUG COLOR] Light supports: CCT=" .. tostring(lightSupportsCCT) .. ", FullColor=" .. tostring(lightSupportsFullColor)) + end + + -- Cancel any existing color ramp timer + if COLOR_RAMP_TIMER then + COLOR_RAMP_TIMER:Cancel() + COLOR_RAMP_TIMER = nil + end + + -- Set up ramp timer to send CHANGED after ramp completes + -- HA reports target state immediately, so we suppress CHANGED during ramp + if rate > 0 then + COLOR_RAMP_PENDING = false + COLOR_RAMP_PENDING_DATA = nil + COLOR_RAMP_TIMER = C4:SetTimer(rate, function(timer) + COLOR_RAMP_TIMER = nil + if DEBUGPRINT then + print("[DEBUG COLOR] Color ramp timer complete, pending=" .. tostring(COLOR_RAMP_PENDING)) + end + -- If we received a state update during ramp, now send CHANGED + if COLOR_RAMP_PENDING and COLOR_RAMP_PENDING_DATA then + COLOR_RAMP_PENDING = false + local data = COLOR_RAMP_PENDING_DATA + COLOR_RAMP_PENDING_DATA = nil + if DEBUGPRINT then + print("[DEBUG COLOR] Forwarding LIGHT_COLOR_CHANGED: X=" .. tostring(data.x) .. ", Y=" .. tostring(data.y) .. ", mode=" .. tostring(data.mode)) + end + C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGED', { + LIGHT_COLOR_CURRENT_X = data.x, + LIGHT_COLOR_CURRENT_Y = data.y, + LIGHT_COLOR_CURRENT_COLOR_MODE = data.mode + }) + end + end) + end + + -- Notify proxy that color is changing (Color Target API) + C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGING', { + LIGHT_COLOR_TARGET_X = targetX, + LIGHT_COLOR_TARGET_Y = targetY, + LIGHT_COLOR_TARGET_COLOR_MODE = colorMode, + LIGHT_COLOR_TARGET_COLOR_RATE = rate + }) local colorServiceCall = { domain = "light", service = "turn_on", - - service_data = { - xy_color = { - targetX, targetY - } - }, - + service_data = {}, target = { entity_id = EntityID } } - tParams = { - JSON = JSON:encode(colorServiceCall) - } + -- Determine what to send based on C4's request and light's capabilities + -- Priority: honor C4's mode if light supports it, otherwise convert + local sendAsCCT = false - C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) + if colorMode == 1 then + -- C4 requests CCT + if lightSupportsCCT then + sendAsCCT = true + else + -- Light doesn't support CCT, send as xy (rare case) + sendAsCCT = false + end + else + -- C4 requests full color (mode=0) + if lightSupportsFullColor then + sendAsCCT = false + elseif lightSupportsCCT then + -- Light only supports CCT, must convert + sendAsCCT = true + end + end + + if sendAsCCT then + -- Send as color_temp_kelvin + local kelvin = C4:ColorXYtoCCT(targetX, targetY) + colorServiceCall.service_data.color_temp_kelvin = kelvin + if DEBUGPRINT then + print("[DEBUG COLOR] Sending as CCT: XY(" .. tostring(targetX) .. "," .. tostring(targetY) .. ") -> " .. tostring(kelvin) .. "K") + end + else + -- Send as xy_color + colorServiceCall.service_data.xy_color = { targetX, targetY } + if DEBUGPRINT then + print("[DEBUG COLOR] Sending as XY: (" .. tostring(targetX) .. "," .. tostring(targetY) .. ")") + end + end + + -- Add transition time (convert ms to seconds) + if rate > 0 then + colorServiceCall.service_data.transition = rate / 1000 + end + + local jsonPayload = JSON:encode(colorServiceCall) + + if DEBUGPRINT then + print("[DEBUG COLOR] SET_COLOR_TARGET sending to HA: " .. jsonPayload) + end + + C4:SendToProxy(999, "HA_CALL_SERVICE", { JSON = jsonPayload }) end +-- Set light brightness with optional transition rate. +-- In fade mode, also calculates and applies interpolated color. function RFP.SET_BRIGHTNESS_TARGET(idBinding, strCommand, tParams) local target = tonumber(tParams.LIGHT_BRIGHTNESS_TARGET) + local rate = tonumber(tParams.RATE) or 0 -- Rate in milliseconds from C4 + local presetId = tParams.LIGHT_BRIGHTNESS_TARGET_PRESET_ID -- Optional preset ID for Daylight Agent + + -- Track preset ID and target level for reporting in LIGHT_BRIGHTNESS_CHANGED + if presetId ~= nil then + LIGHT_BRIGHTNESS_PRESET_ID = tonumber(presetId) + LIGHT_BRIGHTNESS_PRESET_LEVEL = target -- Track target level for this preset + else + LIGHT_BRIGHTNESS_PRESET_ID = nil + LIGHT_BRIGHTNESS_PRESET_LEVEL = nil + end + + -- DEBUG: Log incoming parameters + if DEBUGPRINT then + print("[DEBUG RAMP] SET_BRIGHTNESS_TARGET: target=" .. tostring(target) .. ", rate=" .. tostring(rate) .. "ms, presetId=" .. tostring(presetId)) + end + + -- Cancel any existing ramp timer + if BRIGHTNESS_RAMP_TIMER then + BRIGHTNESS_RAMP_TIMER:Cancel() + BRIGHTNESS_RAMP_TIMER = nil + end + + -- Set up ramp timer to send CHANGED after ramp completes + -- HA reports target state immediately, so we suppress CHANGED during ramp + if rate > 0 then + BRIGHTNESS_RAMP_PENDING = false + BRIGHTNESS_RAMP_TIMER = C4:SetTimer(rate, function(timer) + BRIGHTNESS_RAMP_TIMER = nil + if DEBUGPRINT then + print("[DEBUG RAMP] Ramp timer complete, LIGHT_LEVEL=" .. tostring(LIGHT_LEVEL) .. ", pending=" .. tostring(BRIGHTNESS_RAMP_PENDING)) + end + -- If we received a state update during ramp, now send CHANGED + if BRIGHTNESS_RAMP_PENDING then + BRIGHTNESS_RAMP_PENDING = false + if DEBUGPRINT then + print("[DEBUG RAMP] Forwarding LIGHT_BRIGHTNESS_CHANGED to director, level=" .. tostring(LIGHT_LEVEL) .. ", presetId=" .. tostring(LIGHT_BRIGHTNESS_PRESET_ID)) + end + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) + end + end) + end + + -- Notify proxy that brightness is changing (Brightness Target API) + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGING', { + LIGHT_BRIGHTNESS_CURRENT = LIGHT_LEVEL, + LIGHT_BRIGHTNESS_TARGET = target, + RATE = rate + }) local targetMappedValue = MapValue(target, 255, 100) local brightnessServiceCall = { @@ -153,12 +730,43 @@ function RFP.SET_BRIGHTNESS_TARGET(idBinding, strCommand, tParams) } } + -- Add transition time for dimmable lights (convert ms to seconds) + -- Always set transition to override HA's default (even when 0 for instant) + if HAS_BRIGHTNESS then + brightnessServiceCall.service_data.transition = rate / 1000 + end + + -- Dim-to-warm: Calculate and apply interpolated color based on brightness + if COLOR_ON_MODE_FADE_ENABLED and target > 0 and COLOR_ON_X and COLOR_FADE_X then + -- Formula: colorFinal = colorDim + (colorOn - colorDim) * brightness * 0.01 + local fadeX = COLOR_FADE_X + (COLOR_ON_X - COLOR_FADE_X) * target * 0.01 + local fadeY = COLOR_FADE_Y + (COLOR_ON_Y - COLOR_FADE_Y) * target * 0.01 + + if DEBUGPRINT then + print("[DEBUG COLOR] Dim-to-warm: brightness=" .. tostring(target) .. "% -> XY(" .. tostring(fadeX) .. "," .. tostring(fadeY) .. ")") + end + + -- Check if light supports CCT or XY + local lightSupportsCCT = HasValue(SUPPORTED_ATTRIBUTES, "color_temp") + if lightSupportsCCT then + local kelvin = C4:ColorXYtoCCT(fadeX, fadeY) + brightnessServiceCall.service_data.color_temp_kelvin = kelvin + if DEBUGPRINT then + print("[DEBUG COLOR] Dim-to-warm: sending as CCT " .. tostring(kelvin) .. "K") + end + else + brightnessServiceCall.service_data.xy_color = { fadeX, fadeY } + end + end + if not HAS_BRIGHTNESS then brightnessServiceCall.service_data = {} end if target == 0 then - brightnessServiceCall.service_data = {} + -- Preserve transition for turn_off + local transition = brightnessServiceCall.service_data.transition + brightnessServiceCall.service_data = { transition = transition } brightnessServiceCall["service"] = "turn_off" end @@ -169,24 +777,23 @@ function RFP.SET_BRIGHTNESS_TARGET(idBinding, strCommand, tParams) C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) end +-- Legacy level commands - redirect to SET_BRIGHTNESS_TARGET function RFP.SET_LEVEL(idBinding, strCommand, tParams) tParams["LIGHT_BRIGHTNESS_TARGET"] = tParams.LEVEL - RFP:SET_BRIGHTNESS_TARGET(strCommand, tParams) end function RFP.GROUP_SET_LEVEL(idBinding, strCommand, tParams) tParams["LIGHT_BRIGHTNESS_TARGET"] = tParams.LEVEL - RFP:SET_BRIGHTNESS_TARGET(strCommand, tParams) end function RFP.GROUP_RAMP_TO_LEVEL(idBinding, strCommand, tParams) tParams["LIGHT_BRIGHTNESS_TARGET"] = tParams.LEVEL - RFP:SET_BRIGHTNESS_TARGET(strCommand, tParams) end +-- Apply a light effect (if supported by HA entity) function RFP.SELECT_LIGHT_EFFECT(idBinding, strCommand, tParams) local brightnessServiceCall = { domain = "light", @@ -208,30 +815,26 @@ function RFP.SELECT_LIGHT_EFFECT(idBinding, strCommand, tParams) C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) end +--[[=========================================================================== + Home Assistant State Handlers +===========================================================================]] + +-- Handle initial state response from HA function RFP.RECEIEVE_STATE(idBinding, strCommand, tParams) local jsonData = JSON:decode(tParams.response) - - local stateData - - if jsonData ~= nil then - stateData = jsonData - end - - Parse(stateData) + if jsonData ~= nil then Parse(jsonData) end end +-- Handle real-time state change events from HA function RFP.RECEIEVE_EVENT(idBinding, strCommand, tParams) local jsonData = JSON:decode(tParams.data) - - local eventData - if jsonData ~= nil then - eventData = jsonData["event"]["data"]["new_state"] + Parse(jsonData["event"]["data"]["new_state"]) end - - Parse(eventData) end +-- Parse HA state and notify C4 proxy of changes. +-- Handles brightness, color, effects, and dynamic capability updates. function Parse(data) if data == nil then print("NO DATA") @@ -253,10 +856,22 @@ function Parse(data) if state ~= nil then if state == "off" then WAS_ON = false - C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', { LIGHT_BRIGHTNESS_CURRENT = 0 }) + LIGHT_LEVEL = 0 + -- If ramping, defer CHANGED notification until ramp completes + if BRIGHTNESS_RAMP_TIMER then + BRIGHTNESS_RAMP_PENDING = true + else + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(0)) + end elseif state == "on" and not HAS_BRIGHTNESS then WAS_ON = true - C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', { LIGHT_BRIGHTNESS_CURRENT = 100 }) + LIGHT_LEVEL = 100 + -- If ramping, defer CHANGED notification until ramp completes + if BRIGHTNESS_RAMP_TIMER then + BRIGHTNESS_RAMP_PENDING = true + else + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(100)) + end elseif state == "on" then WAS_ON = true end @@ -269,19 +884,63 @@ function Parse(data) local selectedAttribute = attributes["brightness"] if selectedAttribute ~= nil then - C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', - { LIGHT_BRIGHTNESS_CURRENT = MapValue(tonumber(selectedAttribute), 100, 255) }) + LIGHT_LEVEL = MapValue(tonumber(selectedAttribute), 100, 255) + -- If ramping, defer CHANGED notification until ramp completes + if BRIGHTNESS_RAMP_TIMER then + BRIGHTNESS_RAMP_PENDING = true + else + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) + end end - selectedAttribute = attributes["xy_color"] - if selectedAttribute ~= nil then - local xyTable = selectedAttribute - + -- Handle color updates from HA + -- Check HA's color_mode to determine if CCT or Full Color + local haColorMode = attributes["color_mode"] -- "color_temp", "xy", "hs", "rgb", etc. + + if haColorMode == "color_temp" then + -- CCT mode: use color_temp_kelvin and convert to XY + local kelvin = attributes["color_temp_kelvin"] + if kelvin ~= nil then + local x, y = C4:ColorCCTtoXY(kelvin) + if DEBUGPRINT then + print("[DEBUG COLOR] HA CCT mode: " .. tostring(kelvin) .. "K -> XY(" .. tostring(x) .. "," .. tostring(y) .. ")") + end + -- If color ramping, defer CHANGED notification until ramp completes + if COLOR_RAMP_TIMER then + COLOR_RAMP_PENDING = true + COLOR_RAMP_PENDING_DATA = { x = x, y = y, mode = 1 } + if DEBUGPRINT then + print("[DEBUG COLOR] LIGHT_COLOR_CHANGED postponed (color ramp in progress)") + end + else + C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGED', { + LIGHT_COLOR_CURRENT_X = x, + LIGHT_COLOR_CURRENT_Y = y, + LIGHT_COLOR_CURRENT_COLOR_MODE = 1 -- CCT mode + }) + end + end + elseif haColorMode ~= nil then + -- Full color mode: use xy_color directly (HA normalizes to xy internally) + local xyTable = attributes["xy_color"] if xyTable ~= nil then - C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGED', - { LIGHT_COLOR_CURRENT_X = xyTable[1], LIGHT_COLOR_CURRENT_Y = xyTable[2] }) - else - print("Invalid X,Y format") + if DEBUGPRINT then + print("[DEBUG COLOR] HA Full Color mode (" .. haColorMode .. "): XY(" .. tostring(xyTable[1]) .. "," .. tostring(xyTable[2]) .. ")") + end + -- If color ramping, defer CHANGED notification until ramp completes + if COLOR_RAMP_TIMER then + COLOR_RAMP_PENDING = true + COLOR_RAMP_PENDING_DATA = { x = xyTable[1], y = xyTable[2], mode = 0 } + if DEBUGPRINT then + print("[DEBUG COLOR] LIGHT_COLOR_CHANGED postponed (color ramp in progress)") + end + else + C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGED', { + LIGHT_COLOR_CURRENT_X = xyTable[1], + LIGHT_COLOR_CURRENT_Y = xyTable[2], + LIGHT_COLOR_CURRENT_COLOR_MODE = 0 -- Full color mode + }) + end end end @@ -332,10 +991,10 @@ function Parse(data) if GetStatesHasColor() then hasColor = true + end - if HasValue(SUPPORTED_ATTRIBUTES, "color_temp") then - hasCCT = true - end + if GetStatesHasCCT() then + hasCCT = true end if hasCCT == false then @@ -351,33 +1010,57 @@ function Parse(data) supports_color_correlated_temperature = hasCCT, color_correlated_temperature_min = MIN_K_TEMP, color_correlated_temperature_max = MAX_K_TEMP, - has_extras = HAS_EFFECTS + has_extras = HAS_EFFECTS, + color_trace_tolerance = COLOR_TRACE_TOLERANCE } C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', tParams, "NOTIFY") end end +-- Check if light supports any color mode function GetStatesHasColor() - return HasValue(SUPPORTED_ATTRIBUTES, "color_temp") or HasValue(SUPPORTED_ATTRIBUTES, "hs") or HasValue(SUPPORTED_ATTRIBUTES, "xy") or HasValue(SUPPORTED_ATTRIBUTES, "rgb") - or HasValue(SUPPORTED_ATTRIBUTES, "rgbw") or HasValue(SUPPORTED_ATTRIBUTES, "rgbww") + return HasValue(SUPPORTED_ATTRIBUTES, "hs") + or HasValue(SUPPORTED_ATTRIBUTES, "xy") or HasValue(SUPPORTED_ATTRIBUTES, "rgb") + or HasValue(SUPPORTED_ATTRIBUTES, "rgbw") or HasValue(SUPPORTED_ATTRIBUTES, "rgbww") end +function GetStatesHasCCT() + return HasValue(SUPPORTED_ATTRIBUTES, "color_temp") + or GetStatesHasColor() + -- Ok, maybe this a bit ambitious to call RGB as CCT capable, but it + -- will display the CCT slider for RGB lights, which is better than not + -- displaying it. I don't knwo if there are other knock on effects. +end + +-- Build XML for current effect state (for Navigator display) function GetEffectsStateXML() return '' end +-- Build XML for effect picker UI function GetEffectsXML() - local extras = "" local items = "" - - extras = - '
' - for _, effect in pairs(EFFECTS_LIST) do items = items .. '' end + return '
' .. items .. '
' +end + +--[[=========================================================================== + Property Change Handlers (OPC.*) +===========================================================================]] + +-- Handle Color Trace Tolerance property change from Composer +function OPC.Color_Trace_Tolerance(value) + COLOR_TRACE_TOLERANCE = tonumber(value) + + if DEBUGPRINT then + print("[DEBUG] OPC.Color_Trace_Tolerance :: Color Trace Tolerance set to: " .. tostring(COLOR_TRACE_TOLERANCE)) + end - return extras .. items .. '
' + C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', { + color_trace_tolerance = COLOR_TRACE_TOLERANCE + }, "NOTIFY") end diff --git a/driver.xml b/driver.xml index 18a52f7..2370010 100644 --- a/driver.xml +++ b/driver.xml @@ -6,7 +6,7 @@ HA Light 09/10/2023 12:00 03/04/2024 12:00 - 106 + 107 lua_gen IP DriverWorks @@ -35,6 +35,14 @@ Off false + + Color Trace Tolerance + RANGED_FLOAT + 0.1 + 3 + 1.5 + false + @@ -53,6 +61,9 @@ 0 100000 True + True + True + 0.01 diff --git a/manifest.xml b/manifest.xml new file mode 100644 index 0000000..08eec26 --- /dev/null +++ b/manifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + From c8a87c5f8a92057b9fa8482ba079063fa764414c Mon Sep 17 00:00:00 2001 From: James Connolly Date: Thu, 25 Dec 2025 05:50:32 -0500 Subject: [PATCH 2/4] bug fixes, refactored --- commands.lua | 450 ++++++++++++++++++++++++++++++++++++--------------- helpers.lua | 212 ++++++++++++++++++++++++ manifest.xml | 1 + 3 files changed, 531 insertions(+), 132 deletions(-) create mode 100644 helpers.lua diff --git a/commands.lua b/commands.lua index 3448da6..162c9db 100644 --- a/commands.lua +++ b/commands.lua @@ -12,7 +12,9 @@ ===============================================================================]] -require('Control4-HA-Base.helpers') +Helpers = require('helpers') + +PROXY_DEVICE_STATE = nil -- Light capabilities (populated from HA state) SUPPORTED_ATTRIBUTES = {} @@ -51,12 +53,20 @@ COLOR_FADE_X = nil COLOR_FADE_Y = nil COLOR_FADE_MODE = nil +-- Defaults for correct switch on behavior +DEFAULT_BRIGHTNESS_RATE = 0 +DEFAULT_COLOR_RATE = 0 +COLOR_PRESET_ORIGIN = 0 -- 1 = Previous, 2 = Preset +PREVIOUS_ON_COLOR_X = nil +PREVIOUS_ON_COLOR_Y = nil +PREVIOUS_ON_COLOR_MODE = nil + --[[=========================================================================== Driver Load Functions Scene color matching tolerance (Delta E in CIE L*a*b* space) ===========================================================================]] -function OnDriverInit() +function DRV.OnDriverInit(init) -- 1. Read Static Capabilities (Hardware definitions from XML) local color_tolerance = C4:GetCapability("color_trace_tolerance") if color_tolerance ~= nil then @@ -75,6 +85,19 @@ function OnDriverInit() end end +function DRV.OnDriverLateInit(init) + local proxyId = C4:GetProxyDevicesById(C4:GetDeviceID()) + local setupResult = C4:SendUIRequest(proxyId, "GET_SETUP", {}) + local setupTable = Helpers.xmlToTable(setupResult) + setupTable = Helpers.convertTableTypes(setupTable) + + DEFAULT_BRIGHTNESS_RATE = setupTable.light_brightness_rate_default + + if DEBUGPRINT then + print("[DEBUG OnDriverLateInit] light_brightness_rate_default set:" .. tostring(DEFAULT_BRIGHTNESS_RATE)) + end +end + --[[=========================================================================== Helper Functions ===========================================================================]] @@ -87,32 +110,192 @@ function BuildBrightnessChangedParams(level) return params end +function SetLightValue( brightnessTarget, rate) + local tParams = { + LIGHT_BRIGHTNESS_TARGET = brightnessTarget + } + + if rate then + tParams.RATE = rate + end + + RFP.SET_BRIGHTNESS_TARGET(nil, nil, tParams) +end + + --[[=========================================================================== Proxy Command Handlers (RFP.*) ===========================================================================]] --- Called by Director to sync current state -function RFP.SYNCHRONIZE(idBinding, strCommand, tParams) - C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) +-- Simple on/off commands (no brightness/color specified) +function RFP.ON(idBinding, strCommand, tParams) + local turnOnServiceCall = { + domain = "light", + service = "turn_on", + + service_data = {}, + + target = { + entity_id = EntityID + } + } + tParams = { + JSON = JSON:encode(turnOnServiceCall) + } + C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) end +function RFP.OFF(idBinding, strCommand, tParams) + local turnOffServiceCall = { + domain = "light", + service = "turn_off", + + service_data = {}, + + target = { + entity_id = EntityID + } + } + tParams = { + JSON = JSON:encode(turnOffServiceCall) + } + C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) +end + +-- Button link handlers -- Handle physical button presses (Top=On, Bottom=Off, Toggle) +-- +-- Bindings: +-- 200=Top +-- 201=Bottom +-- 202=Toggle +-- +-- From the c4 docs for BUTTON_ACTION(): +-- +-- BUTTON_ID. ID for the button: +-- 0 - Top +-- 1 - Bottom +-- 2 - Toggle +-- ACTION : +-- 0 - RELEASE (HOLD) +-- 1 - PRESS +-- 2 - RELEASE (CLICK) + +function RFP.DO_PUSH(idBinding, strCommand, tParams) + local tParams = { ACTION = "1", BUTTON_ID = "" } + if idBinding == 200 then tParams.BUTTON_ID = "0" + elseif idBinding == 201 then tParams.BUTTON_ID = "1" + elseif idBinding == 202 then tParams.BUTTON_ID = "2" end + RFP:BUTTON_ACTION(strCommand, tParams) +end + +function RFP.DO_RELEASE(idBinding, strCommand, tParams) + local tParams = { ACTION = "0", BUTTON_ID = "" } + if idBinding == 200 then tParams.BUTTON_ID = "0" + elseif idBinding == 201 then tParams.BUTTON_ID = "1" + elseif idBinding == 202 then tParams.BUTTON_ID = "2" end + RFP:BUTTON_ACTION(strCommand, tParams) +end + +function RFP.DO_CLICK(idBinding, strCommand, tParams) + local tParams = { ACTION = "2", BUTTON_ID = "" } + if idBinding == 200 then tParams.BUTTON_ID = "0" + elseif idBinding == 201 then tParams.BUTTON_ID = "1" + elseif idBinding == 202 then tParams.BUTTON_ID = "2" end + RFP:BUTTON_ACTION(strCommand, tParams) +end + function RFP.BUTTON_ACTION(idBinding, strCommand, tParams) + + -- PRESS physical action ... dont know if this will be a + -- CLICK or a HOLD, so start a long ramp immediately in + -- the correct direction ... the CLICK is coming immenently + -- and we'll correct, or else it's a HOLD. + -- The lack of of a stop command in HA makes this difficult. + if tParams.ACTION == "1" then + local rate = 5000. -- this is controling the press & hold ramp rate. + RAMP_START_TIME_MS = C4:GetTime() -- system time in ms. + RAMP_DURATION_MS = rate + RAMP_START_LEVEL = LIGHT_LEVEL -- Current level when ramp starts + + if tParams.BUTTON_ID == "0" then + RAMP_TARGET_LEVEL = 100 + SetLightValue(RAMP_TARGET_LEVEL, rate) + elseif tParams.BUTTON_ID == "1" then + -- ridiculous situation where a turn_off followed by + -- an overriding turn_off at HA results in immediate + -- turn off. So turn_on to low (1%), then turn_off + -- if it turns out to be a click event. + RAMP_TARGET_LEVEL = 1 + SetLightValue(RAMP_TARGET_LEVEL, rate) + else + if WAS_ON then + RAMP_TARGET_LEVEL = 1 + SetLightValue(RAMP_TARGET_LEVEL, rate) + else + RAMP_TARGET_LEVEL = 100 + SetLightValue(RAMP_TARGET_LEVEL, rate) + end + end + end + + -- RELEASE from a HOLD event + -- Set the brightness to the current level. + if tParams.ACTION == "0" then + SetLightValue(Helpers.lerp(RAMP_START_LEVEL, + RAMP_TARGET_LEVEL, + C4:GetTime() - RAMP_START_TIME_MS, + RAMP_DURATION_MS), 0) + end + + -- Release from a CLICK event if tParams.ACTION == "2" then if tParams.BUTTON_ID == "0" then - SetLightValue(100) + SetLightValue(100, nil) elseif tParams.BUTTON_ID == "1" then - SetLightValue(0) + SetLightValue(0, nil) else if WAS_ON then - SetLightValue(0) + SetLightValue(0, nil) else - SetLightValue(100) + SetLightValue(100, nil) end end end end +-- Called by Director to sync current state +function RFP.SYNCHRONIZE(idBinding, strCommand, tParams) + C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) +end + +function RFP.UPDATE_BRIGHTNESS_RATE_DEFAULT(idBinding, strCommand, tParams) + DEFAULT_BRIGHTNESS_RATE = tonumber(tParams.RATE) or 0 + if DEBUGPRINT then + print("[DEBUG] Default brightness rate set to: " .. tostring(DEFAULT_BRIGHTNESS_RATE) .. "ms") + end +end + +function RFP.UPDATE_COLOR_RATE_DEFAULT(idBinding, strCommand, tParams) + DEFAULT_COLOR_RATE = tonumber(tParams.RATE) or 0 + if DEBUGPRINT then + print("[DEBUG] Default color rate set to: " .. tostring(DEFAULT_COLOR_RATE) .. "ms") + end +end + +function RFP.UPDATE_COLOR_PRESET(idBinding, strCommand, tParams) + -- Track "Previous On" color when light turns off + if tParams.NAME == "Previous On" then + PREVIOUS_ON_COLOR_X = tonumber(tParams.COLOR_X) + PREVIOUS_ON_COLOR_Y = tonumber(tParams.COLOR_Y) + PREVIOUS_ON_COLOR_MODE = tonumber(tParams.COLOR_MODE) + if DEBUGPRINT then + print("[DEBUG COLOR] Previous On color updated: XY(" .. + tostring(PREVIOUS_ON_COLOR_X) .. "," .. tostring(PREVIOUS_ON_COLOR_Y) .. ")") + end + end +end + --[[=========================================================================== Advanced Lighting Scene (ALS) Handlers @@ -289,7 +472,7 @@ function RFP.ACTIVATE_SCENE(idBinding, strCommand, tParams) return end - -- Execute brightness only (no color in scene) + -- Execute brightness if levelEnabled or (el.level ~= nil or el.brightness ~= nil) then RFP.SET_BRIGHTNESS_TARGET(nil, nil, { LIGHT_BRIGHTNESS_TARGET = el.level or el.brightness or 0, @@ -297,7 +480,7 @@ function RFP.ACTIVATE_SCENE(idBinding, strCommand, tParams) }) end - -- Execute color only (no brightness in scene) + -- Execute color if colorEnabled then RFP.SET_COLOR_TARGET(nil, nil, { LIGHT_COLOR_TARGET_X = el.colorX, @@ -325,18 +508,18 @@ function RFP.RAMP_SCENE_UP(idBinding, strCommand, tParams) end local target = el.level or el.brightness or 100 - SCENE_RAMP_START_TIME_MS = C4:GetTime() -- system time in ms. - SCENE_RAMP_DURATION_MS = rate - SCENE_RAMP_START_LEVEL = LIGHT_LEVEL -- Current level when ramp starts - SCENE_RAMP_TARGET_LEVEL = target + RAMP_START_TIME_MS = C4:GetTime() -- system time in ms. + RAMP_DURATION_MS = rate + RAMP_START_LEVEL = LIGHT_LEVEL -- Current level when ramp starts + RAMP_TARGET_LEVEL = target if DEBUGPRINT then print("rate = " .. tostring(rate)) print("target = " .. tostring(target)) - print("SCENE_RAMP_START_TIME_MS = " .. tostring(SCENE_RAMP_START_TIME_MS)) - print("SCENE_RAMP_DURATION_MS = " .. tostring(SCENE_RAMP_DURATION_MS)) - print("SCENE_RAMP_START_LEVEL = " .. tostring(SCENE_RAMP_START_LEVEL)) - print("SCENE_RAMP_TARGET_LEVEL = " .. tostring(SCENE_RAMP_TARGET_LEVEL)) + print("RAMP_START_TIME_MS = " .. tostring(RAMP_START_TIME_MS)) + print("RAMP_DURATION_MS = " .. tostring(RAMP_DURATION_MS)) + print("RAMP_START_LEVEL = " .. tostring(RAMP_START_LEVEL)) + print("RAMP_TARGET_LEVEL = " .. tostring(RAMP_TARGET_LEVEL)) end -- Ramp to scene's target brightness @@ -358,11 +541,19 @@ function RFP.RAMP_SCENE_DOWN(idBinding, strCommand, tParams) local el = C4:PersistGetValue("ALS:" .. sceneId, false) local target = el.level or el.brightness or 100 - print("[DEBUG ALS] RAMP_SCENE_DOWN: scene=" .. tostring(sceneId) .. ", target=" .. tostring(target)) - SCENE_RAMP_START_TIME_MS = C4:GetTime() -- system time in ms. - SCENE_RAMP_DURATION_MS = rate - SCENE_RAMP_START_LEVEL = LIGHT_LEVEL -- Current level when ramp starts - SCENE_RAMP_TARGET_LEVEL = 0 + RAMP_START_TIME_MS = C4:GetTime() -- system time in ms. + RAMP_DURATION_MS = rate + RAMP_START_LEVEL = LIGHT_LEVEL -- Current level when ramp starts + RAMP_TARGET_LEVEL = 0 + + if DEBUGPRINT then + print("rate = " .. tostring(rate)) + print("target = " .. tostring(target)) + print("RAMP_START_TIME_MS = " .. tostring(RAMP_START_TIME_MS)) + print("RAMP_DURATION_MS = " .. tostring(RAMP_DURATION_MS)) + print("RAMP_START_LEVEL = " .. tostring(RAMP_START_LEVEL)) + print("RAMP_TARGET_LEVEL = " .. tostring(RAMP_TARGET_LEVEL)) + end -- Ramp to 0 RFP.SET_BRIGHTNESS_TARGET(nil, nil, { @@ -374,13 +565,12 @@ end -- Stop an in-progress scene ramp (user released button). -- Sends current level to HA to freeze at current position. function RFP.STOP_SCENE_RAMP(idBinding, strCommand, tParams) - local elapsedTimeMs = C4:GetTime() - SCENE_RAMP_START_TIME_MS + local elapsedTimeMs = C4:GetTime() - RAMP_START_TIME_MS local sceneId = tParams.SCENE_ID if DEBUGPRINT then print("[DEBUG ALS] STOP_SCENE_RAMP: scene=" .. tostring(sceneId) .. ", freezing at level=" .. tostring(LIGHT_LEVEL)) - print("[DEBUG ALS] STOP_SCENE_RAMP: tParams:") - DumpTable(tParams) + Helpers.dumpTable(tParams, "RFP.STOP_SCENE_RAMP tParams") end -- Cancel any pending ramp timer @@ -389,10 +579,10 @@ function RFP.STOP_SCENE_RAMP(idBinding, strCommand, tParams) BRIGHTNESS_RAMP_TIMER = nil end - newTargetLevel = Lerp(SCENE_RAMP_START_LEVEL, - SCENE_RAMP_TARGET_LEVEL, + newTargetLevel = Helpers.lerp(RAMP_START_LEVEL, + RAMP_TARGET_LEVEL, elapsedTimeMs, - SCENE_RAMP_DURATION_MS) + RAMP_DURATION_MS) if DEBUGPRINT then print("[DEBUG ALS] STOP_SCENE_RAMP: elapsedTimeMs=" .. tostring(elapsedTimeMs) .. ", newTargetLevel=" .. tostring(newTargetLevel)) @@ -421,12 +611,12 @@ end -- In "Fade" mode, stores On/Dim colors for dim-to-warm interpolation. function RFP.UPDATE_COLOR_ON_MODE(idBinding, strCommand, tParams) if DEBUGPRINT then - print("[DEBUG COLOR] UPDATE_COLOR_ON_MODE received:") - for k, v in pairs(tParams) do - print("[DEBUG COLOR] " .. tostring(k) .. " = " .. tostring(v)) - end + Helpers.dumpTable(tParams, "RFP.UPDATE_COLOR_ON_MODE tParams") end + -- Track origin: 1 = Previous, 2 = Preset + COLOR_PRESET_ORIGIN = tonumber(tParams.COLOR_PRESET_ORIGIN) or 0 + -- Store the "On" color (at 100% brightness) COLOR_ON_X = tonumber(tParams.COLOR_PRESET_COLOR_X) COLOR_ON_Y = tonumber(tParams.COLOR_PRESET_COLOR_Y) @@ -437,72 +627,21 @@ function RFP.UPDATE_COLOR_ON_MODE(idBinding, strCommand, tParams) COLOR_FADE_Y = tonumber(tParams.COLOR_FADE_PRESET_COLOR_Y) COLOR_FADE_MODE = tonumber(tParams.COLOR_FADE_PRESET_COLOR_MODE) - -- Fade mode is enabled if we have both On and Dim colors defined - COLOR_ON_MODE_FADE_ENABLED = (COLOR_FADE_X ~= nil) and (COLOR_ON_X ~= nil) + -- Fade mode is enabled if fade preset ID is non-zero and we have both colors + local fadePresetId = tonumber(tParams.COLOR_FADE_PRESET_ID) or 0 + COLOR_ON_MODE_FADE_ENABLED = (fadePresetId ~= 0) and (COLOR_FADE_X ~= nil) and (COLOR_ON_X ~= nil) if DEBUGPRINT then - print("[DEBUG COLOR] UPDATE_COLOR_ON_MODE: fade_enabled=" .. tostring(COLOR_ON_MODE_FADE_ENABLED)) + print("[DEBUG COLOR] UPDATE_COLOR_ON_MODE: origin=" .. tostring(COLOR_PRESET_ORIGIN) .. + ", fade_enabled=" .. tostring(COLOR_ON_MODE_FADE_ENABLED)) print("[DEBUG COLOR] On color: X=" .. tostring(COLOR_ON_X) .. ", Y=" .. tostring(COLOR_ON_Y)) print("[DEBUG COLOR] Dim color: X=" .. tostring(COLOR_FADE_X) .. ", Y=" .. tostring(COLOR_FADE_Y)) end end --- Simple on/off commands (no brightness/color specified) -function RFP.ON(idBinding, strCommand, tParams) - local turnOnServiceCall = { - domain = "light", - service = "turn_on", - - service_data = {}, - - target = { - entity_id = EntityID - } - } - tParams = { - JSON = JSON:encode(turnOnServiceCall) - } - C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) -end - -function RFP.OFF(idBinding, strCommand, tParams) - local turnOffServiceCall = { - domain = "light", - service = "turn_off", - - service_data = {}, - - target = { - entity_id = EntityID - } - } - tParams = { - JSON = JSON:encode(turnOffServiceCall) - } - C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) -end - --- Button link handlers (bindings 200=Top, 201=Bottom, 202=Toggle) -function RFP.DO_PUSH(idBinding, strCommand, tParams) end -function RFP.DO_RELEASE(idBinding, strCommand, tParams) end - -function RFP.DO_CLICK(idBinding, strCommand, tParams) - local tParams = { ACTION = "2", BUTTON_ID = "" } - if idBinding == 200 then tParams.BUTTON_ID = "0" - elseif idBinding == 201 then tParams.BUTTON_ID = "1" - elseif idBinding == 202 then tParams.BUTTON_ID = "2" end - RFP:BUTTON_ACTION(strCommand, tParams) -end -function SetLightValue(value) - local tParams = { - LIGHT_BRIGHTNESS_TARGET = value - } - - RFP.SET_BRIGHTNESS_TARGET(nil, nil, tParams) -end - --- Set light color. Converts C4 XY coordinates to HA format (CCT or xy_color). +-- Set light color. +-- Converts C4 XY coordinates to CCT if light only supports CCT. -- In fade mode, ignores commands matching On/Dim presets to prevent override. function RFP.SET_COLOR_TARGET(idBinding, strCommand, tParams) local targetX = tonumber(tParams.LIGHT_COLOR_TARGET_X) @@ -665,24 +804,32 @@ end -- Set light brightness with optional transition rate. -- In fade mode, also calculates and applies interpolated color. function RFP.SET_BRIGHTNESS_TARGET(idBinding, strCommand, tParams) + + if DEBUGPRINT then + Helpers.dumpTable(tParams, "RFP.SET_BRIGHTNESS_TARGET tParams") + end + local target = tonumber(tParams.LIGHT_BRIGHTNESS_TARGET) - local rate = tonumber(tParams.RATE) or 0 -- Rate in milliseconds from C4 - local presetId = tParams.LIGHT_BRIGHTNESS_TARGET_PRESET_ID -- Optional preset ID for Daylight Agent + local rate = tonumber(tParams.RATE) + local presetId = tParams.LIGHT_BRIGHTNESS_TARGET_PRESET_ID + + -- Apply default rate if not specified + if rate == nil then + rate = DEFAULT_BRIGHTNESS_RATE + end + + -- Detect off→on transition + local turningOn = (LIGHT_LEVEL == 0 or not WAS_ON) and target > 0 -- Track preset ID and target level for reporting in LIGHT_BRIGHTNESS_CHANGED if presetId ~= nil then LIGHT_BRIGHTNESS_PRESET_ID = tonumber(presetId) - LIGHT_BRIGHTNESS_PRESET_LEVEL = target -- Track target level for this preset + LIGHT_BRIGHTNESS_PRESET_LEVEL = target else LIGHT_BRIGHTNESS_PRESET_ID = nil LIGHT_BRIGHTNESS_PRESET_LEVEL = nil end - -- DEBUG: Log incoming parameters - if DEBUGPRINT then - print("[DEBUG RAMP] SET_BRIGHTNESS_TARGET: target=" .. tostring(target) .. ", rate=" .. tostring(rate) .. "ms, presetId=" .. tostring(presetId)) - end - -- Cancel any existing ramp timer if BRIGHTNESS_RAMP_TIMER then BRIGHTNESS_RAMP_TIMER:Cancel() @@ -690,26 +837,22 @@ function RFP.SET_BRIGHTNESS_TARGET(idBinding, strCommand, tParams) end -- Set up ramp timer to send CHANGED after ramp completes - -- HA reports target state immediately, so we suppress CHANGED during ramp if rate > 0 then BRIGHTNESS_RAMP_PENDING = false BRIGHTNESS_RAMP_TIMER = C4:SetTimer(rate, function(timer) BRIGHTNESS_RAMP_TIMER = nil if DEBUGPRINT then - print("[DEBUG RAMP] Ramp timer complete, LIGHT_LEVEL=" .. tostring(LIGHT_LEVEL) .. ", pending=" .. tostring(BRIGHTNESS_RAMP_PENDING)) + print("[DEBUG RAMP] Ramp timer complete, LIGHT_LEVEL=" .. tostring(LIGHT_LEVEL) .. + ", pending=" .. tostring(BRIGHTNESS_RAMP_PENDING)) end - -- If we received a state update during ramp, now send CHANGED if BRIGHTNESS_RAMP_PENDING then BRIGHTNESS_RAMP_PENDING = false - if DEBUGPRINT then - print("[DEBUG RAMP] Forwarding LIGHT_BRIGHTNESS_CHANGED to director, level=" .. tostring(LIGHT_LEVEL) .. ", presetId=" .. tostring(LIGHT_BRIGHTNESS_PRESET_ID)) - end C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGED', BuildBrightnessChangedParams(LIGHT_LEVEL)) end end) end - -- Notify proxy that brightness is changing (Brightness Target API) + -- Notify proxy that brightness is changing C4:SendToProxy(5001, 'LIGHT_BRIGHTNESS_CHANGING', { LIGHT_BRIGHTNESS_CURRENT = LIGHT_LEVEL, LIGHT_BRIGHTNESS_TARGET = target, @@ -720,61 +863,100 @@ function RFP.SET_BRIGHTNESS_TARGET(idBinding, strCommand, tParams) local brightnessServiceCall = { domain = "light", service = "turn_on", - service_data = { brightness = targetMappedValue }, - target = { entity_id = EntityID } } - -- Add transition time for dimmable lights (convert ms to seconds) - -- Always set transition to override HA's default (even when 0 for instant) + -- Add transition time for dimmable lights if HAS_BRIGHTNESS then + print("[DEBUG RAMP] SET_BRIGHTNESS_TARGET: Adding transition time of " .. tostring(rate) .. "ms") brightnessServiceCall.service_data.transition = rate / 1000 end - -- Dim-to-warm: Calculate and apply interpolated color based on brightness - if COLOR_ON_MODE_FADE_ENABLED and target > 0 and COLOR_ON_X and COLOR_FADE_X then - -- Formula: colorFinal = colorDim + (colorOn - colorDim) * brightness * 0.01 - local fadeX = COLOR_FADE_X + (COLOR_ON_X - COLOR_FADE_X) * target * 0.01 - local fadeY = COLOR_FADE_Y + (COLOR_ON_Y - COLOR_FADE_Y) * target * 0.01 + -- Determine color to apply + local colorX, colorY, colorMode = nil, nil, nil - if DEBUGPRINT then - print("[DEBUG COLOR] Dim-to-warm: brightness=" .. tostring(target) .. "% -> XY(" .. tostring(fadeX) .. "," .. tostring(fadeY) .. ")") + if target > 0 then + if COLOR_ON_MODE_FADE_ENABLED and COLOR_ON_X and COLOR_FADE_X then + -- Fade/Dim-to-warm: Always interpolate color based on brightness level + colorX = COLOR_FADE_X + (COLOR_ON_X - COLOR_FADE_X) * target * 0.01 + colorY = COLOR_FADE_Y + (COLOR_ON_Y - COLOR_FADE_Y) * target * 0.01 + colorMode = COLOR_ON_MODE + + if DEBUGPRINT then + print("[DEBUG COLOR] Dim-to-warm: brightness=" .. tostring(target) .. + "% -> XY(" .. tostring(colorX) .. "," .. tostring(colorY) .. ")") + end + elseif turningOn then + -- Not fade mode, but turning on - check preset vs previous + if COLOR_PRESET_ORIGIN == 1 and PREVIOUS_ON_COLOR_X then + -- Previous mode: restore last color + colorX = PREVIOUS_ON_COLOR_X + colorY = PREVIOUS_ON_COLOR_Y + colorMode = PREVIOUS_ON_COLOR_MODE + + if DEBUGPRINT then + print("[DEBUG COLOR] Applying previous on-color: XY(" .. + tostring(colorX) .. "," .. tostring(colorY) .. ")") + end + elseif COLOR_PRESET_ORIGIN == 2 and COLOR_ON_X then + -- Preset mode: apply configured preset color + colorX = COLOR_ON_X + colorY = COLOR_ON_Y + colorMode = COLOR_ON_MODE + + if DEBUGPRINT then + print("[DEBUG COLOR] Applying preset on-color: XY(" .. + tostring(colorX) .. "," .. tostring(colorY) .. ")") + end + end end - -- Check if light supports CCT or XY - local lightSupportsCCT = HasValue(SUPPORTED_ATTRIBUTES, "color_temp") - if lightSupportsCCT then - local kelvin = C4:ColorXYtoCCT(fadeX, fadeY) - brightnessServiceCall.service_data.color_temp_kelvin = kelvin - if DEBUGPRINT then - print("[DEBUG COLOR] Dim-to-warm: sending as CCT " .. tostring(kelvin) .. "K") + -- Apply color if determined + if colorX and colorY then + local lightSupportsCCT = HasValue(SUPPORTED_ATTRIBUTES, "color_temp") + if lightSupportsCCT and (colorMode == 1 or colorMode == nil) then + local kelvin = C4:ColorXYtoCCT(colorX, colorY) + brightnessServiceCall.service_data.color_temp_kelvin = kelvin + if DEBUGPRINT then + print("[DEBUG COLOR] Sending as CCT " .. tostring(kelvin) .. "K") + end + else + brightnessServiceCall.service_data.xy_color = { colorX, colorY } + if DEBUGPRINT then + print("[DEBUG COLOR] Sending as XY") + end end - else - brightnessServiceCall.service_data.xy_color = { fadeX, fadeY } + + -- Notify proxy of color change + C4:SendToProxy(5001, 'LIGHT_COLOR_CHANGING', { + LIGHT_COLOR_TARGET_X = colorX, + LIGHT_COLOR_TARGET_Y = colorY, + LIGHT_COLOR_TARGET_COLOR_MODE = colorMode or 0, + LIGHT_COLOR_TARGET_COLOR_RATE = DEFAULT_COLOR_RATE + }) end end + if not HAS_BRIGHTNESS then brightnessServiceCall.service_data = {} end if target == 0 then - -- Preserve transition for turn_off local transition = brightnessServiceCall.service_data.transition brightnessServiceCall.service_data = { transition = transition } brightnessServiceCall["service"] = "turn_off" end - tParams = { - JSON = JSON:encode(brightnessServiceCall) - } - C4:SendToProxy(999, "HA_CALL_SERVICE", tParams) + Helpers.dumpTable(brightnessServiceCall, "Brightness Service Call to HA") + + C4:SendToProxy(999, "HA_CALL_SERVICE", { JSON = JSON:encode(brightnessServiceCall) }) end -- Legacy level commands - redirect to SET_BRIGHTNESS_TARGET @@ -1063,4 +1245,8 @@ function OPC.Color_Trace_Tolerance(value) C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', { color_trace_tolerance = COLOR_TRACE_TOLERANCE }, "NOTIFY") + + C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', { + color_trace_tolerance = 0.01 + }, "NOTIFY") end diff --git a/helpers.lua b/helpers.lua new file mode 100644 index 0000000..645c46d --- /dev/null +++ b/helpers.lua @@ -0,0 +1,212 @@ +local Helpers = {} + +-- Linear Interpolation helper +function Helpers.lerp(startVal, endVal, elapsed, duration) + if duration <= 0 then return endVal end + local t = elapsed / duration + if t > 1 then t = 1 end + return startVal + (endVal - startVal) * t +end + +-- Clear print output of nested and other complex tables +function Helpers.dumpTable(t, label) + + local topLabelLen = 0 + if label then + local topLabel = string.format("\n================= %s =================", tostring(label)) + topLabelLen = #topLabel - 1 + print(topLabel) + end + + if type(t) ~= "table" then + print("dumpTable error: Expected table, got " .. type(t) .. " (" .. tostring(t) .. ")") + if label then + print(string.rep("=", topLabelLen)) + end + return + end + + local function recurse(tbl, indent, visited) + visited = visited or {} + if visited[tbl] then + print(indent .. "") + return + end + visited[tbl] = true + + local keys = {} + for k in pairs(tbl) do keys[#keys + 1] = k end + + -- Handle empty tables explicitly + if #keys == 0 then + print(indent .. "") + return + end + + table.sort(keys, function(a, b) + local ta = type(tbl[a]) == "table" and 1 or 0 + local tb = type(tbl[b]) == "table" and 1 or 0 + if ta ~= tb then return ta < tb end + return tostring(a) < tostring(b) + end) + + for _, k in ipairs(keys) do + local v = tbl[k] + if type(v) == "table" then + print(indent .. tostring(k) .. ":") + recurse(v, indent .. " ", visited) + else + print(indent .. tostring(k) .. " = " .. tostring(v)) + end + end + end + + recurse(t, "") + + if label then + print(string.rep("=", topLabelLen)) + end +end + +-- Internal helper to parse XML attributes +local function parseArgs(s) + local arg = {} + string.gsub(s, "([%w_:]+)%s*=%s*([\"'])(.-)%2", function(w, _, a) + arg[w] = a + end) + return arg +end + + +function Helpers.xmlToTable(xmlString) + local stack = {} + local top = {} + table.insert(stack, top) + local i, j = 1, 1 + + while true do + local ni, nj, closing, label, xarg, empty = string.find(xmlString, "<(%/?)([%w_:]+)(.-)(%/?)>", i) + if not ni then break end + + -- Handle text content between tags + local text = string.sub(xmlString, i, ni - 1) + if not string.find(text, "^%s*$") then + -- If we have text content, store it in a special key + top.xmlTextContent = (top.xmlTextContent or "") .. text + end + + if empty == "/" then -- Self-closing tag + local node = parseArgs(xarg) + -- If parent already has this key, turn it into a list + if top[label] then + if not top[label][1] then top[label] = { top[label] } end + table.insert(top[label], node) + else + top[label] = node + end + + elseif closing == "" then -- Start tag + local node = parseArgs(xarg) + table.insert(stack, node) + -- Track hierarchy + node.parentRef = top + node.labelRef = label + top = node + + else -- End tag + local toClose = table.remove(stack) + top = stack[#stack] + + -- Clean up the text content + if toClose.xmlTextContent then + toClose.xmlTextContent = toClose.xmlTextContent:match("^%s*(.-)%s*$") + end + + local currentLabel = toClose.labelRef + toClose.parentRef = nil + toClose.labelRef = nil + + -- LOGIC: If the node has NO attributes and NO children, reduce it to a simple value + local isComplex = false + for k, v in pairs(toClose) do + if k ~= "xmlTextContent" then isComplex = true break end + end + + local value = toClose + if not isComplex and toClose.xmlTextContent then + value = toClose.xmlTextContent + end + + -- Insert into parent + if top[currentLabel] then + if type(top[currentLabel]) ~= "table" or (type(top[currentLabel]) == "table" and not top[currentLabel][1]) then + top[currentLabel] = { top[currentLabel] } -- Convert existing single item to list + end + table.insert(top[currentLabel], value) + else + top[currentLabel] = value + end + end + i = nj + 1 + end + + -- The XML root is usually the single key in the top container + for k, v in pairs(top) do return v end +end + +-- Convert all the stringified numbers to strings in a table. +-- This is common for tables returned from XML or JSON parsing. +function Helpers.convertTableTypes(tbl, overrides) + local DEFAULT_KEYWORDS = { + "NAME", "TEXT", "LABEL", "ID", "VERSION", "STATUS", "DESCRIPTION" + } + + local protectedList = (type(overrides) == "table" and #overrides > 0) + and overrides + or DEFAULT_KEYWORDS + + local protected = {} + for _, word in ipairs(protectedList) do + protected[word:upper()] = true + end + + local function process(t) + for k, v in pairs(t) do + if (type(v) == "table") then + process(v) + elseif (type(v) == "string") then + local upperKey = tostring(k):upper() + local isProtected = false + + for word in pairs(protected) do + if (upperKey:find(word, 1, true)) then + isProtected = true + break + end + end + + if (not isProtected) then + local lowerVal = v:lower() + + if (lowerVal == "true") then + t[k] = true + elseif (lowerVal == "false") then + t[k] = false + elseif (v:match("^%x%x%x%x%x%x$")) then + -- Keep Hex + elseif (v:match("^0%d+") and not v:match("^0%.") and v ~= "0") then + -- Keep Padded String + else + local num = tonumber(v) + if (num) then t[k] = num end + end + end + end + end + end + + process(tbl) + return tbl +end + +return Helpers \ No newline at end of file diff --git a/manifest.xml b/manifest.xml index 08eec26..3b57a08 100644 --- a/manifest.xml +++ b/manifest.xml @@ -3,6 +3,7 @@ + From 668b1872b4a6fb5e9060f1e76f687f95aced5447 Mon Sep 17 00:00:00 2001 From: James Connolly Date: Tue, 30 Dec 2025 05:57:54 -0500 Subject: [PATCH 3/4] V107 alpha release --- CHANGELOG-v107.md | 72 ++++++++++++++++++++++------------------------- Control4-HA-Base | 2 +- commands.lua | 36 ++---------------------- driver.xml | 9 ------ 4 files changed, 37 insertions(+), 82 deletions(-) diff --git a/CHANGELOG-v107.md b/CHANGELOG-v107.md index 3266619..434363a 100644 --- a/CHANGELOG-v107.md +++ b/CHANGELOG-v107.md @@ -2,21 +2,29 @@ ## Summary -### New Features -- **Color On Mode Previous** - Enables the "Previous" color restore option in Composer Pro -- **Color On Mode Fade (Dim-to-Warm)** - Linear color interpolation between dim and bright colors based on brightness level -- **Configurable Color Trace Tolerance** - Adjustable Delta E tolerance for scene color matching - -### Fixes -- **Transition Rate Handling** - Brightness and color ramp times now properly respected +### New / Improved / Fixed +- Transition Rate Handling - Brightness and color ramp times now properly respected in default driver properties page and Advanced Lighting Agent Scene (ALS) definitions. - **Advanced Lighting Scenes (ALS)** - Full `advanced_scene_support` API implementation -- **Scene Color Tracking** - Scenes now correctly mark as "Active" when color matches within tolerance +- **Scene Color Tracking** - Scenes now correctly mark as "Active" when color matches within tolerance (see TODO below) -### Improvements - **Preset ID Support** - Daylight Agent preset tracking for brightness commands - **Combined Scene Commands** - Brightness and color sent together to prevent visual artifacts -- **Ramp Timer Management** - Deferred state notifications during transitions for accurate scene tracking +- **Ramp Timer Management** - Deferred state notifications during transitions for accurate scene tracking and UI state tracking + +- Color On Mode Preset +- **Color On Mode Previous** - Enables the "Previous" color restore option in Composer Pro +- **Color On Mode Fade (Dim-to-Warm)** - Linear color interpolation between dim and bright colors based on brightness level + +### TODO +- **Configurable Color Trace Tolerance** - Tried adding an Adjustable Delta E tolerance for scene color matching. + Changes to the value are not being respected by the Advanced Lighting scene tracking. + The tolerance in CCT space seems to be fixed at around 110 kelvin in the 4000K region. + I'm sure this works for almost everyone and not many have a need for color tracking for scene activation but it's not working and I believe being ignored by the director. + +- **Push and Hold to Ramp** - Applies anything where you push and hold to transition. This is currently working for brightness via an undesirable workaround. + CCT and Color will be difficult or impossible to get working well without a light.stop interface exposed by HomeAssistant to arrest the progress of the ramp. + Home Assistant's lack of a stop method and minimal state updates during transitions necessitate way more complexity than desirable in this interface. --- ## Technical Details @@ -65,9 +73,9 @@ brightnessServiceCall.service_data.color_temp_kelvin = C4:ColorXYtoCCT(fadeX, fa ### 3. Suppressing Unwanted Color Commands in Fade Mode -**Discovery**: When fade mode is active, the C4 proxy periodically sends `SET_COLOR_TARGET` commands with the preset On or Dim color values. These commands would override the driver's calculated fade color, causing the light to jump to the preset color instead of the interpolated color. +When fade mode is active, the C4 proxy periodically sends `SET_COLOR_TARGET` commands with the preset On or Dim color values. These commands would override the driver's calculated fade color, causing the light to jump to the preset color instead of the interpolated color. -**Solution**: The driver compares incoming `SET_COLOR_TARGET` coordinates against the stored preset colors and ignores commands that match: +The driver now compares incoming `SET_COLOR_TARGET` coordinates against the stored preset colors and ignores commands that match: ```lua if COLOR_ON_MODE_FADE_ENABLED then @@ -93,13 +101,13 @@ end The tolerance of 0.005 in XY space accounts for floating-point rounding. Commands with different colors (e.g., scene activations, manual color changes) pass through normally. -**Limitation**: If the Composer UI has other color presets configured that don't match the current On/Dim presets, those sync commands will still reach the driver. This appears to be a Composer configuration issue rather than a driver issue. +If the Composer UI has other color presets configured that don't match the current On/Dim presets, those sync commands will still reach the driver. ### 4. Ramp Timer Management -**Discovery**: Home Assistant reports the target state immediately when a transition command is sent, rather than waiting for the transition to complete. If the driver forwards this state to C4 immediately, C4's scene tracking compares the target against the current (mid-transition) state and may incorrectly mark the scene as inactive. +Home Assistant reports the new target state almost immediately when a transition command is sent, rather than waiting for the transition to complete, or sending gradual updates as the luminaire transitions. If the driver forwards this state to C4 when received, C4's user interface elements jump to the fully transitioned state and the ALS scene goes "Active" at the start rather than the end of the transition. -**Solution**: When a brightness or color command includes a rate > 0, the driver: +When a brightness or color command includes a rate > 0, the driver: 1. Sets a timer for the duration of the ramp 2. Defers `LIGHT_BRIGHTNESS_CHANGED` and `LIGHT_COLOR_CHANGED` notifications until the timer expires 3. Stores pending state data if HA reports during the ramp @@ -129,11 +137,10 @@ end ### 5. Combined Scene Commands -**Discovery**: When `ACTIVATE_SCENE` includes both brightness and color, the driver originally executed them as separate HA commands. With dim-to-warm enabled, this caused a visual flash: -1. `SET_BRIGHTNESS_TARGET` applied the dim-to-warm interpolated color -2. Milliseconds later, `SET_COLOR_TARGET` applied the scene's actual color +When `ACTIVATE_SCENE` includes both brightness and color, the driver originally executed them as separate HA commands. +With dim-to-warm enabled, this caused visual flashing and inconsistent state transitions. -**Solution**: When a scene has both brightness and color enabled, send them as a single HA service call: +When a scene has both brightness and color enabled, send them as a single HA service call: ```lua if (levelEnabled or el.level ~= nil or el.brightness ~= nil) and colorEnabled then @@ -154,7 +161,11 @@ end ### 6. C4 Color Conversion Functions -**Critical**: Always use C4's native color conversion functions to stay within C4's color space. Using external formulas produces different XY values that break scene tracking. +Always use C4's native color conversion functions to stay within C4's color space. C4 uses an implementation for CCT and XY chromaticity that doesn't precisely match well known formulas. +They also round CCT temps down to the nearest 10k which causes matching issues if you aim for higher precision. +Just use their color conversion routines whenever possible. +Be aware also that there is rounding in Home Assistant and this is particularly bad when it's in mired space. Generally we care about CCT's in the 200-400 mired range and rounding down from 199.99 mireds to 199 mireds is a 25k deviation. +Natively, c4 will always manage colors in XY space. Available functions: - `C4:ColorCCTtoXY(kelvin)` - Kelvin to CIE 1931 xy @@ -174,7 +185,7 @@ The round-trip through C4's conversion ensures matching XY coordinates: ### 7. Color Trace Tolerance -The `color_trace_tolerance` capability controls scene color matching precision. Exposed as a configurable property in Composer Pro (range 0.5 to 10.0, default 1.0). +Can't get this to work. The `color_trace_tolerance` capability controls scene color matching precision. ```lua function OPC.Color_Trace_Tolerance(value) @@ -185,11 +196,12 @@ function OPC.Color_Trace_Tolerance(value) end ``` +Enabling the feature in driver.xml and changing the property at runtime seems to propagate everywhere but I can't tune any more or less color temp tolerance in two ALS scenes that are 'close' but not matching. As mentioned above, around 4000K there is ~110K tolerance to be considered "Active". This works for most applications and addresses the stepping and rounding issues discussed above in almost all cases. + Comparison methods (handled by C4 Director): - Delta > 0.01: Uses CIE L*a*b* Delta E formula - Delta <= 0.01: Uses xy chromaticity Euclidean distance -Most humans detect color differences at Delta E >= 3.0. ### 8. Preset ID Support @@ -253,19 +265,3 @@ This capability requires implementing the following commands: **Performance requirement**: The driver uses the Brightness Target API (`LIGHT_BRIGHTNESS_CHANGING`/`LIGHT_BRIGHTNESS_CHANGED`) and only sends one level update when the hardware reaches the final scene level. This is achieved through ramp timer management that defers `CHANGED` notifications until the transition completes. ---- - -## Files Modified - -- `driver.xml` - Added `color_on_mode_previous` and `color_on_mode_fade` capabilities -- `commands.lua` - All handler implementations - -## Testing Notes - -1. Enable Debug Mode in Composer Pro to see detailed logging -2. Test dim-to-warm by adjusting brightness and observing color temperature -3. Test scene activation with both brightness and color enabled -4. Verify scene shows "Active" status after activation -5. Test transition rates by observing ramp timing matches configured values -6. Test scene ramp up/down by holding buttons in Navigator (if keypads support it) -7. Verify PUSH_SCENE stores data correctly (check debug output on driver load) diff --git a/Control4-HA-Base b/Control4-HA-Base index 1e01f3f..7fc2ac9 160000 --- a/Control4-HA-Base +++ b/Control4-HA-Base @@ -1 +1 @@ -Subproject commit 1e01f3fec9cb0cb123ec5f7942b9a7c9d1cad43d +Subproject commit 7fc2ac9f758dd137e8f6ef8fec4a9fd6a066a392 diff --git a/commands.lua b/commands.lua index 162c9db..24a77ae 100644 --- a/commands.lua +++ b/commands.lua @@ -67,22 +67,7 @@ PREVIOUS_ON_COLOR_MODE = nil ===========================================================================]] function DRV.OnDriverInit(init) - -- 1. Read Static Capabilities (Hardware definitions from XML) - local color_tolerance = C4:GetCapability("color_trace_tolerance") - if color_tolerance ~= nil then - local parsed = tonumber(color_tolerance) - - if parsed then - COLOR_TRACE_TOLERANCE = parsed - print("Driver Init: COLOR_TRACE_TOLERANCE set to " .. COLOR_TRACE_TOLERANCE) - else - -- Explicit failure logging - print("[ERROR] Driver XML Capability 'color_trace_tolerance' defined but invalid: '" .. tostring(color_tolerance) .. "'. Expected a number.") - -- Optionally: leave COLOR_TOLERANCE as nil to force a crash later if that's preferred. - end - else - print("[WARNING] Driver XML Capability 'color_trace_tolerance' is missing.") - end + end function DRV.OnDriverLateInit(init) @@ -1192,8 +1177,7 @@ function Parse(data) supports_color_correlated_temperature = hasCCT, color_correlated_temperature_min = MIN_K_TEMP, color_correlated_temperature_max = MAX_K_TEMP, - has_extras = HAS_EFFECTS, - color_trace_tolerance = COLOR_TRACE_TOLERANCE + has_extras = HAS_EFFECTS } C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', tParams, "NOTIFY") @@ -1234,19 +1218,3 @@ end Property Change Handlers (OPC.*) ===========================================================================]] --- Handle Color Trace Tolerance property change from Composer -function OPC.Color_Trace_Tolerance(value) - COLOR_TRACE_TOLERANCE = tonumber(value) - - if DEBUGPRINT then - print("[DEBUG] OPC.Color_Trace_Tolerance :: Color Trace Tolerance set to: " .. tostring(COLOR_TRACE_TOLERANCE)) - end - - C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', { - color_trace_tolerance = COLOR_TRACE_TOLERANCE - }, "NOTIFY") - - C4:SendToProxy(5001, 'DYNAMIC_CAPABILITIES_CHANGED', { - color_trace_tolerance = 0.01 - }, "NOTIFY") -end diff --git a/driver.xml b/driver.xml index 2370010..bde6ba9 100644 --- a/driver.xml +++ b/driver.xml @@ -35,14 +35,6 @@ Off false - - Color Trace Tolerance - RANGED_FLOAT - 0.1 - 3 - 1.5 - false - @@ -63,7 +55,6 @@ True True True - 0.01 From aa975932840893a442412c9d9de6e0b62a356813 Mon Sep 17 00:00:00 2001 From: James Connolly Date: Tue, 30 Dec 2025 06:42:50 -0500 Subject: [PATCH 4/4] typos --- CHANGELOG-v107.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG-v107.md b/CHANGELOG-v107.md index 434363a..427936e 100644 --- a/CHANGELOG-v107.md +++ b/CHANGELOG-v107.md @@ -3,7 +3,7 @@ ## Summary ### New / Improved / Fixed -- Transition Rate Handling - Brightness and color ramp times now properly respected in default driver properties page and Advanced Lighting Agent Scene (ALS) definitions. +- **Transition Rate Handling** - Brightness and color ramp times now properly respected in default driver properties page and Advanced Lighting Agent Scene (ALS) definitions. - **Advanced Lighting Scenes (ALS)** - Full `advanced_scene_support` API implementation - **Scene Color Tracking** - Scenes now correctly mark as "Active" when color matches within tolerance (see TODO below) @@ -11,7 +11,7 @@ - **Combined Scene Commands** - Brightness and color sent together to prevent visual artifacts - **Ramp Timer Management** - Deferred state notifications during transitions for accurate scene tracking and UI state tracking -- Color On Mode Preset +- **Color On Mode Preset** - **Color On Mode Previous** - Enables the "Previous" color restore option in Composer Pro - **Color On Mode Fade (Dim-to-Warm)** - Linear color interpolation between dim and bright colors based on brightness level