Aeotec Smart Switch 7 - color fix and other enhancements

I asked ChatGPT and Claude to create a driver for the Aeotec Smart Switch 7 since I was having issues controlling parameters and color with the built-in driver. The parent driver and child (LED) driver are inline below. Hope this helps.

import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovy.transform.Field
/*
* Aeotec Smart Switch 7 US (ZWA023) - experimental parent v3.4
* Hubitat C-8 Pro / Z-Wave JS. Test on one device first.
* Refresh synchronizes reported configuration values into Preferences; no factory reset or child deletion.
*
* v3.4 changes (from v3.3):
* - Configuration parameter readbacks are logged only when diagnostic (debug) logging is on.
* - With debug logging on, every parameter returned is logged with a readable label and value
* (previously only parameters 1-5 were logged, and always at info level).
*
* v3.3 changes (from v3.2):
* - LED brightness is capped at 99 (Z-Wave Switch Multilevel maximum); 100 was an invalid value.
* "LED On" now restores the last non-zero brightness (default 99).
* - Night-light times are logged with correct 0xHHMMSS decoding (18:00 no longer logs as 24:00).
* - setConfigurationParameter refuses parameter 255 (Aeotec reset) and parameter 0.
* - Pending-write markers expire after 60 seconds, so a lost reply no longer blocks re-saving a value.
* - Saving Preferences sends one evenly paced command list instead of many parallel bursts.
* - Supervision Get is answered with a Supervision Report (avoids device retransmissions).
* - Z-Wave JS reports: only currentValue/currentColor properties update state
* (a Binary Switch "duration" or "targetValue" can no longer flip the switch to off).
* - LED color is read back after a color change; RGB is sent at full value and the color map's
* level is applied once through LED brightness (no double dimming).
* - Debug logging turns itself off after 30 minutes; added Reset Energy command.
* - Removed undeclared diagnostic/test methods that nothing could call.
*/
metadata {
definition(name: 'Aeotec Smart Switch 7 US - custom driver', namespace: 'custom.aeotec', author: 'ChatGPT') {
capability 'Actuator'
capability 'Switch'
capability 'Outlet'
capability 'Refresh'
capability 'Configuration'
capability 'Initialize'
capability 'PowerMeter'
capability 'EnergyMeter'
capability 'VoltageMeasurement'
capability 'CurrentMeter'
command 'refreshLed'
command 'resetEnergy'
command 'setConfigurationParameter', [[name:'Parameter number', type:'NUMBER'], [name:'Length (1, 2 or 4 bytes)', type:'NUMBER'], [name:'Value', type:'NUMBER']]
command 'getConfigurationParameter', [[name:'Parameter number', type:'NUMBER']]
attribute 'configurationParameters', 'string'
attribute 'ledOperatingMode', 'string'
attribute 'nightlightOnTime', 'string'
attribute 'nightlightOffTime', 'string'
attribute 'ledRgbReported', 'string'
command 'setLedColor', [[name:'Color map', type:'COLOR_MAP']]
command 'setLedRGB', [[name:'Red 0-255', type:'NUMBER'], [name:'Green 0-255', type:'NUMBER'], [name:'Blue 0-255', type:'NUMBER']]
command 'setLedLevel', [[name:'Level', type:'NUMBER']]
command 'setLedOn'
command 'setLedOff'
fingerprint mfr:'0371', prod:'0103', model:'0017', deviceJoinName:'Aeotec Smart Switch 7 US'
}
preferences {
input name:'ledOperatingModePreference', type:'enum', title:'LED Operating Mode', options:['unchanged':'Leave unchanged','0':'Disabled','1':'Nightlight','2':'Status'], defaultValue:'unchanged'
input name:'nightlightOnTime', type:'text', title:'Nightlight ON Time (24-hour HH:mm, 00:00-23:59)', required:false
input name:'nightlightOffTime', type:'text', title:'Nightlight OFF Time (24-hour HH:mm, 00:00-23:59)', required:false
input name:'overVoltageProtection', type:'number', title:'Over Voltage Protection (0 disables; 95-255 volts)', required:false, range:'0..255'
input name:'energyInterval', type:'number', title:'Energy kWh Interval Reporting (0 disables; 1-65535 seconds; 1-30 reports every 30 seconds)', required:false, range:'0..65535'
input name:'wattInterval', type:'number', title:'Watt Interval Reporting (0 disables; 1-65535 seconds; 1-30 reports every 30 seconds)', required:false, range:'0..65535'
input name:'voltageInterval', type:'number', title:'Voltage Interval Reporting (0 disables; 1-65535 seconds; 1-30 reports every 30 seconds)', required:false, range:'0..65535'
input name:'powerRestoreState', type:'number', title:'Default Current State After Power Failure (0 restore last state; 1 ON; 2 OFF)', required:false, range:'0..2'
input name:'energyThreshold', type:'number', title:'Energy kWh Threshold Reporting (0 disables; 1-10000 kWh)', required:false, range:'0..10000'
input name:'wattThreshold', type:'number', title:'Watt Threshold Reporting (0 disables; 1-2500 watts)', required:false, range:'0..2500'
input name:'ampThreshold', type:'number', title:'Amp Threshold Reporting (0 disables; 1-150 in 0.1 amp increments)', required:false, range:'0..150'
input name:'powerFailReporting', type:'number', title:'Switch State Change Reporting (0 disables; 1 Basic Report; 2 Switch Binary Report)', required:false, range:'0..2'
input name:'currentInterval', type:'number', title:'Current Amp Interval Reporting (0 disables; 1-65535 seconds; 1-30 reports every 30 seconds)', required:false, range:'0..65535'
input name:'debugLogging', type:'bool', title:'Enable diagnostic logging (turns off after 30 minutes)', defaultValue:false
input name:'descriptionLogging', type:'bool', title:'Enable description logging', defaultValue:true
// Additional documented configuration parameters (shown at bottom of Preferences).
input name:'overloadProtection', type:'number', title:'Overload Protection (0 disables; 1-1800 watts)', required:false, range:'0..1800'
input name:'thresholdCheckInterval', type:'number', title:'Threshold Check Interval (0 disables all threshold checks; 1-255 seconds)', required:false, range:'0..255'
input name:'autoOffTimer', type:'number', title:'Automatic OFF Timer (0 disables; 1-86400 seconds after outlet turns ON)', required:false, range:'0..86400'
input name:'autoOnTimer', type:'number', title:'Automatic ON Timer (0 disables; 1-86400 seconds after outlet turns OFF)', required:false, range:'0..86400'
}
}

@Field static final long PENDING_TIMEOUT_MS = 60000L
@Field static final int LED_MAX_LEVEL = 99

def installed() {
log.info "${device.displayName}: v3.4 installed; leaving configuration unchanged"
runIn(3, 'ensureLedChild')
}
def updated() {
log.info "${device.displayName}: v3.4 preferences saved"
unschedule('logsOff')
if(debugLogging) runIn(1800, 'logsOff')
ensureLedChild()
List cmds = []
cmds.addAll(ledOperatingModeCommands())
cmds.addAll(meteringPreferenceCommands())
cmds.addAll(nightlightTimeCommands())
if(cmds) {
log.info "${device.displayName}: sending ${cmds.size()} configuration commands"
sendCommands(cmds, 500)
}
}
def configure() {
ensureLedChild()
List cmds = ledOperatingModeCommands()
cmds.addAll(refreshCommands())
sendCommands(cmds, 600)
runIn(Math.max(22, (int)(cmds.size() * 0.6) + 4), 'refreshLed')
}
def initialize() { ensureLedChild(); refresh() }

def logsOff() {
device.updateSetting('debugLogging', [value:'false', type:'bool'])
log.info "${device.displayName}: diagnostic logging disabled"
}

def ensureLedChild() {
if(getChildDevices()) return
createLedChild()
}

// ---------------------------------------------------------------------------
// Configuration writes. Each *Commands() method returns a command list
// (set + verifying get per parameter) and sends nothing by itself.
// ---------------------------------------------------------------------------

private List ledOperatingModeCommands() {
String requested = settings.ledOperatingModePreference?.toString() ?: 'unchanged'
if(requested == 'unchanged') return []
// Only write if the requested mode differs from the last reported device value.
if(state.ledConfigParameter1?.toString() == requested) return []
Integer value = requested as Integer
if(isPending(1, value)) return []
markPending(1, value)
return configCommands(1, 1, value)
}

// Blank fields do not manage the device. Only explicitly populated preferences are written.
// Compare against last reported value to avoid redundant Z-Wave traffic.
private List meteringPreferenceCommands() {
List cmds = []
def definitions = [
[name:'overVoltageProtection', parameter:11, size:1, min:0, max:255],
[name:'energyInterval', parameter:24, size:2, min:0, max:65535],
[name:'wattInterval', parameter:23, size:2, min:0, max:65535],
[name:'voltageInterval', parameter:25, size:2, min:0, max:65535],
[name:'powerRestoreState', parameter:8, size:1, min:0, max:2],
[name:'energyThreshold', parameter:20, size:2, min:0, max:10000],
[name:'wattThreshold', parameter:21, size:2, min:0, max:2500],
[name:'ampThreshold', parameter:22, size:1, min:0, max:150],
[name:'powerFailReporting', parameter:42, size:1, min:0, max:2],
[name:'currentInterval', parameter:26, size:2, min:0, max:65535],
[name:'overloadProtection', parameter:10, size:2, min:0, max:1800],
[name:'thresholdCheckInterval', parameter:19, size:1, min:0, max:255],
[name:'autoOffTimer', parameter:40, size:4, min:0, max:86400],
[name:'autoOnTimer', parameter:41, size:4, min:0, max:86400],
]
definitions.each { item ->
def selected = settings[item.name]
if(selected == null || selected.toString().trim() == '') return
try {
long wanted = new BigDecimal(selected.toString().trim()).longValueExact()
if(wanted < item.min || wanted > item.max || (item.parameter == 11 && wanted != 0 && wanted < 95)) {
log.warn "${device.displayName}: ${item.name} invalid value ${wanted} (parameter ${item.parameter})"
return
}
def reported = state.reportedConfigurationParameters instanceof Map ? state.reportedConfigurationParameters[item.parameter.toString()] : null
if(reported != null && reported.toString() == wanted.toString()) return
// Avoid repeated writes while awaiting a configuration report (expires after 60 s).
if(isPending(item.parameter, wanted)) return
markPending(item.parameter, wanted)
cmds.addAll(configCommands(item.parameter, item.size, wanted))
} catch(Exception ex) { log.warn "${device.displayName}: invalid preference ${item.name}: ${ex.message}" }
}
return cmds
}

// Aeotec nightlight time: 4-byte 0xRRHHMMSS, clock fields written as hex digits (18:00 = 0x180000).
// Preserve RR on write, and set seconds to zero.
private Integer bcdByte(Integer decimal) { ((int)(decimal / 10) << 4) | (decimal % 10) }
private Integer fromBcdByte(Integer encoded) {
int tens = (encoded >> 4) & 0xF
int ones = encoded & 0xF
if(tens > 9 || ones > 9) return null
return tens * 10 + ones
}
private String decodeNightlightTime(Object raw, boolean withSeconds=false) {
long value = Long.parseLong(raw.toString())
Integer hour = fromBcdByte((int)((value >> 16) & 0xFF))
Integer minute = fromBcdByte((int)((value >> 8) & 0xFF))
Integer second = fromBcdByte((int)(value & 0xFF))
if(hour == null || minute == null || second == null || hour > 23 || minute > 59 || second > 59) return null
return withSeconds ? String.format('%02d:%02d:%02d', hour, minute, second) : String.format('%02d:%02d', hour, minute)
}
private List nightlightTimeCommands() {
List cmds = []
[[name:'nightlightOnTime', parameter:2], [name:'nightlightOffTime', parameter:3]].each { item ->
String chosen = settings[item.name]?.toString()?.trim()
if(!chosen) return
if(!(chosen ==~ /(?:[01][0-9]|2[0-3]):[0-5][0-9]/)) {
log.warn "${device.displayName}: ${item.name} must be HH:mm (00:00-23:59); received ${chosen}"
return
}
Map reported = state.reportedConfigurationParameters instanceof Map ? state.reportedConfigurationParameters : [:]
def actual = reported[item.parameter.toString()]
if(actual == null) {
log.warn "${device.displayName}: parameter ${item.parameter} not read yet; press Refresh before changing ${item.name}"
return
}
if(decodeNightlightTime(actual) == chosen) return
int hour = Integer.parseInt(chosen.substring(0,2))
int minute = Integer.parseInt(chosen.substring(3,5))
long preservedTop = Long.parseLong(actual.toString()) & 0xFF000000L
long encoded = preservedTop | ((long)bcdByte(hour) << 16) | ((long)bcdByte(minute) << 8)
if(isPending(item.parameter, encoded)) return
markPending(item.parameter, encoded)
cmds.addAll(configCommands(item.parameter, 4, encoded))
}
return cmds
}

private boolean isPending(Integer parameter, long value) {
def pending = state["pendingConfig${parameter}"]
if(!(pending instanceof Map)) return false // v3.2 stored a bare number; treat as expired
if(pending.value?.toString() != value.toString()) return false
return (now() - ((pending.at ?: 0L) as Long)) < PENDING_TIMEOUT_MS
}
private void markPending(Integer parameter, long value) {
state["pendingConfig${parameter}"] = [value:value, at:now()]
}

// Validates and builds ConfigurationSet + ConfigurationGet. Returns [] when rejected.
private List configCommands(Object parameter, Object length, Object value) {
try {
int p = Integer.parseInt(parameter.toString())
int n = Integer.parseInt(length.toString())
long v = new BigDecimal(value.toString()).longValueExact()
if(p == 255) throw new IllegalArgumentException('parameter 255 is the Aeotec reset parameter and is blocked by this driver')
if(p < 1 || p > 254) throw new IllegalArgumentException('parameter must be 1-254')
if(!(n in [1,2,4])) throw new IllegalArgumentException('length must be 1, 2 or 4')
long min = -(1L << (8*n-1))
long max = (1L << (8*n))-1
if(v < min || v > max) throw new IllegalArgumentException("value must be ${min} through ${max} for ${n} bytes")
// Unsigned values above the signed range are sent as their two's-complement equivalent,
// which produces the same bytes on the wire.
long signedMax = (1L << (8*n-1)) - 1
int wire = (int)(v > signedMax ? v - (1L << (8*n)) : v)
log.info "${device.displayName}: writing parameter ${p}, length ${n}, value ${v}"
return [
secureCmd(zwave.configurationV2.configurationSet(parameterNumber:p, size:n, scaledConfigurationValue:wire)),
secureCmd(zwave.configurationV2.configurationGet(parameterNumber:p))
]
} catch(Exception e) {
log.error "${device.displayName}: parameter write rejected: ${e.message}"
return []
}
}

def setConfigurationParameter(Object parameter, Object length, Object value) {
List cmds = configCommands(parameter, length, value)
if(cmds) sendCommands(cmds, 450)
}
def getConfigurationParameter(Object parameter) {
try {
int p = Integer.parseInt(parameter.toString())
if(p < 1 || p > 255) throw new IllegalArgumentException('parameter must be 1-255')
sendCommands([secureCmd(zwave.configurationV2.configurationGet(parameterNumber:p))])
} catch(Exception e) { log.error "${device.displayName}: parameter read rejected: ${e.message}" }
}

private void recordConfigurationParameter(Integer parameter, Object value) {
if(parameter == null) return
// Retain a single map rather than one state variable per parameter.
// Remove legacy per-parameter entries left by earlier driver versions.
(state.keySet().findAll { it.toString() ==~ /configParam\d+/ }).each { key -> state.remove(key) }
state.remove("pendingConfig${parameter}")
Map reported = (state.reportedConfigurationParameters instanceof Map) ? state.reportedConfigurationParameters : [:]
reported[parameter.toString()] = value
state.reportedConfigurationParameters = reported
String json = JsonOutput.toJson(reported)
if(device.currentValue('configurationParameters') != json) sendEvent(name:'configurationParameters', value:json)
// Synchronize actual reported values into the Hubitat Preferences UI.
// Never synthesize a value for a parameter that did not report.
Map preferenceNames = [
1:'ledOperatingModePreference', 8:'powerRestoreState',
11:'overVoltageProtection', 20:'energyThreshold',
21:'wattThreshold', 22:'ampThreshold', 23:'wattInterval',
24:'energyInterval', 25:'voltageInterval', 26:'currentInterval',
10:'overloadProtection', 19:'thresholdCheckInterval',
40:'autoOffTimer', 41:'autoOnTimer',
42:'powerFailReporting'
]
if(parameter in [2,3]) {
String timeSetting = parameter == 2 ? 'nightlightOnTime' : 'nightlightOffTime'
String formatted = decodeNightlightTime(value)
if(formatted != null) {
sendEvent(name:timeSetting, value:formatted)
try {
if(settings[timeSetting]?.toString() != formatted) device.updateSetting(timeSetting, [value:formatted, type:'text'])
} catch(Exception ex) { log.warn "${device.displayName}: time preference synchronization failed: ${ex.message}" }
} else log.warn "${device.displayName}: cannot decode parameter ${parameter} as a clock time: ${value}"
}
String preferenceName = preferenceNames[parameter]
if(preferenceName != null) {
try {
String actual = value.toString()
// A readback following a write supersedes the old preference.
if(settings[preferenceName]?.toString() != actual) {
device.updateSetting(preferenceName, [value:actual, type:(parameter == 1 ? 'enum' : 'number')])
}
} catch(Exception ex) {
log.warn "${device.displayName}: could not synchronize preference for parameter ${parameter}: ${ex.message}"
}
}
if(parameter == 1) {
state.ledConfigParameter1 = value
Integer modeNumber = safeInt(value)
String mode = (modeNumber != null && modeNumber in [0,1,2]) ? ['Disabled','Nightlight','Status'][modeNumber] : "Unknown (${value})"
sendEvent(name:'ledOperatingMode', value:mode)
}
if(debugLogging) logConfigurationParameter(parameter, value)
}

// Labels for the parameters this driver reads on Refresh. Parameters without a label are
// still logged, by number and raw value.
@Field static final Map<Integer,String> PARAMETER_LABELS = [
1:'LED mode (0=disabled, 1=night-light, 2=status)',
2:'Night-light ON time',
3:'Night-light OFF time',
4:'LED blink duration (seconds; 0=no blink)',
5:'LED blink speed (blinks/second)',
8:'State after power failure (0=restore last, 1=ON, 2=OFF)',
10:'Overload protection (watts; 0=disabled)',
11:'Over-voltage protection (volts; 0=disabled)',
19:'Threshold check interval (seconds; 0=disabled)',
20:'Energy threshold reporting (kWh; 0=disabled)',
21:'Watt threshold reporting (W; 0=disabled)',
22:'Amp threshold reporting (0.1 A; 0=disabled)',
23:'Watt interval reporting (seconds; 0=disabled)',
24:'Energy interval reporting (seconds; 0=disabled)',
25:'Voltage interval reporting (seconds; 0=disabled)',
26:'Current interval reporting (seconds; 0=disabled)',
40:'Automatic OFF timer (seconds; 0=disabled)',
41:'Automatic ON timer (seconds; 0=disabled)',
42:'Switch state change reporting (0=disabled, 1=Basic, 2=Switch Binary)'
]

private void logConfigurationParameter(Integer n, def val) {
if(n == null) return
String label = PARAMETER_LABELS[n] ?: 'Parameter'
String shown = val?.toString()
if(n in [2,3] && val != null) {
try {
String formatted = decodeNightlightTime(val, true)
shown = formatted ?: "undecodable (${String.format('0x%08X', val as Long)})"
} catch(Exception ignored) { }
}
log.debug "${device.displayName}: ${label} (#${n}) = ${shown}"
}

// ---------------------------------------------------------------------------
// Command plumbing
// ---------------------------------------------------------------------------

private String secureCmd(cmd) { zwaveSecureEncap(cmd) }
private String endpointCmd(cmd, Integer ep=2) {
secureCmd(zwave.multiChannelV3.multiChannelCmdEncap(destinationEndPoint:ep, sourceEndPoint:0, bitAddress:false).encapsulate(cmd))
}
private void sendCommands(List commands, Integer spacing=250) {
def valid = commands.findAll { it }
if(valid) sendHubCommand(new hubitat.device.HubMultiAction(delayBetween(valid, spacing), hubitat.device.Protocol.ZWAVE))
}

// ---------------------------------------------------------------------------
// Outlet
// ---------------------------------------------------------------------------

def on() { sendCommands([secureCmd(zwave.switchBinaryV1.switchBinarySet(switchValue:255)), secureCmd(zwave.switchBinaryV1.switchBinaryGet())]) }
def off() { sendCommands([secureCmd(zwave.switchBinaryV1.switchBinarySet(switchValue:0)), secureCmd(zwave.switchBinaryV1.switchBinaryGet())]) }

private List refreshCommands() {
List cmds = [
secureCmd(zwave.switchBinaryV1.switchBinaryGet()),
endpointCmd(zwave.meterV4.meterGet(scale:2), 1),
endpointCmd(zwave.meterV4.meterGet(scale:0), 1),
endpointCmd(zwave.meterV4.meterGet(scale:4), 1),
endpointCmd(zwave.meterV4.meterGet(scale:5), 1)
]
// Read documented parameters. Parameter 255 (reset) is never touched.
[1,2,3,4,5,6,7,8,9,10,11,19,20,21,22,23,24,25,26,30,31,32,40,41,42].each { n ->
cmds << secureCmd(zwave.configurationV2.configurationGet(parameterNumber:n))
}
return cmds
}
def refresh() {
log.info "${device.displayName}: v3.4 Refresh started"
try {
List cmds = refreshCommands()
sendCommands(cmds, 600)
// Request LED color after the configuration reads finish (30 x 600 ms = 18 s).
runIn(22, 'refreshLed')
} catch(Exception e) {
log.error "${device.displayName}: Refresh failed: ${e.class.simpleName}: ${e.message}"
}
}

def resetEnergy() {
log.info "${device.displayName}: resetting accumulated energy"
sendCommands([
secureCmd(zwave.meterV3.meterReset()),
endpointCmd(zwave.meterV3.meterReset(), 1),
endpointCmd(zwave.meterV4.meterGet(scale:0), 1)
], 600)
}

// ---------------------------------------------------------------------------
// LED (endpoint 2)
// ---------------------------------------------------------------------------

def refreshLed() {
if(debugLogging) log.debug "${device.displayName}: LED endpoint 2 refresh requested"
// Discard cached RGB so we only publish a complete fresh triplet.
state.remove('ledRgb2')
state.remove('ledRgb3')
state.remove('ledRgb4')
sendCommands([
endpointCmd(zwave.switchMultilevelV2.switchMultilevelGet()),
endpointCmd(zwave.switchColorV3.switchColorGet(colorComponent:2)),
endpointCmd(zwave.switchColorV3.switchColorGet(colorComponent:3)),
endpointCmd(zwave.switchColorV3.switchColorGet(colorComponent:4))
], 450)
}

// Existing children are preserved. Hubitat built-in component children may not
// allow changing their driver type. Component callbacks are implemented below.
def createLedChild() {
def children = getChildDevices()
if(children) {
log.warn "${device.displayName}: ${children.size()} existing child(ren); no child created. ${children.collect { it.deviceNetworkId + ' (' + it.typeName + ')' }}"
return
}
String dni = "${device.deviceNetworkId}-LED"
try {
addChildDevice('custom.aeotec', 'Aeotec Smart Switch 7 US - custom driver - LED', dni,
[name:"${device.displayName} LED", label:"${device.displayName} LED", isComponent:true])
log.info "${device.displayName}: created LED child ${dni}"
} catch(Exception e) { log.error "Unable to create LED child: ${e.message}" }
}
private def ledChild() {
def children = getChildDevices()
def chosen = children?.find { it.deviceNetworkId?.endsWith('-LED') } ?: children?.find {
it.displayName?.toLowerCase()?.contains('led') || it.typeName?.toLowerCase()?.contains('led') || it.typeName?.toLowerCase()?.contains('color')
}
if(!chosen && children?.size() == 1) chosen = children[0]
return chosen
}
private void ledEvent(String name, value, String unit=null) {
def child = ledChild()
if(!child) {
if(debugLogging) log.debug "${device.displayName}: LED ${name}=${value}; no child found"
return
}
try {
def evt = [name:name, value:value]
if(unit) evt.unit = unit
child.sendEvent(evt)
} catch(Exception e) {
log.warn "${device.displayName}: LED child event ${name} failed: ${e.message}"
}
}
// Hubitat component callbacks used by existing built-in component children.
def componentOn(cd) { log.info "${device.displayName}: LED componentOn ${cd?.deviceNetworkId}"; setLedOn() }
def componentOff(cd) { log.info "${device.displayName}: LED componentOff ${cd?.deviceNetworkId}"; setLedOff() }
def componentSetLevel(cd, level) { log.info "${device.displayName}: LED componentSetLevel ${level}"; setLedLevel(level as Number) }
def componentSetLevel(cd, level, duration) { componentSetLevel(cd, level) }
def componentSetColor(cd, Map color) { log.info "${device.displayName}: LED componentSetColor ${color}"; setLedColor(color) }
def componentSetHue(cd, hue) { setLedColor([hue:hue, saturation:cd?.currentValue('saturation') ?: 100]) }
def componentSetSaturation(cd, sat) { setLedColor([hue:cd?.currentValue('hue') ?: 0, saturation:sat]) }
def componentRefresh(cd) { log.info "${device.displayName}: LED componentRefresh"; refreshLed() }

// Color is sent at full value; brightness is controlled only by Switch Multilevel on
// endpoint 2. When the color map includes a level, it is applied as LED brightness.
def setLedColor(Map color) {
log.info "${device.displayName}: LED color requested ${color}"
int hue = Math.max(0, Math.min(100, (color.hue != null ? color.hue : 0) as int))
int sat = Math.max(0, Math.min(100, (color.saturation != null ? color.saturation : 100) as int))
def rgb = hubitat.helper.ColorUtils.hsvToRGB([hue, sat, 100])
// Hubitat hue is 0-100, so primaries do not land exactly (34 = ~122 degrees).
// Snap fully saturated colors within one hue unit of a primary/secondary to the exact color.
if(sat == 100) {
Map<Integer,List> exact = [
0:[255,0,0], 17:[255,255,0], 33:[0,255,0],
50:[0,255,255], 67:[0,0,255], 83:[255,0,255], 100:[255,0,0]
]
Integer near = exact.keySet().find { Math.abs(hue - it) <= 1 }
if(near != null) rgb = exact[near]
}
List cmds = [ledRgbCommand(rgb[0] as Integer, rgb[1] as Integer, rgb[2] as Integer)]
if(color.level != null) cmds.addAll(ledLevelCommands(color.level as Number))
sendCommands(cmds, 300)
runIn(3, 'refreshLed')
}

def setLedRGB(Number red, Number green, Number blue) {
int r = Math.max(0, Math.min(255, red.intValue()))
int g = Math.max(0, Math.min(255, green.intValue()))
int b = Math.max(0, Math.min(255, blue.intValue()))
log.info "${device.displayName}: LED RGB requested R=${r} G=${g} B=${b}"
sendCommands([ledRgbCommand(r, g, b)])
runIn(3, 'refreshLed')
}

// Same native command path as the tested ZWA023 diagnostic driver:
// Switch Color V3 set with components 2/3/4, Multi Channel V4 to endpoint 2.
private String ledRgbCommand(Integer red, Integer green, Integer blue) {
def colorCommand = zwave.switchColorV3.switchColorSet(colorComponents: [2:red, 3:green, 4:blue])
def endpointCommand = zwave.multiChannelV4.multiChannelCmdEncap(destinationEndPoint: 2).encapsulate(colorCommand)
if(debugLogging) log.debug "${device.displayName}: LED RGB TX ep2 R=${red} G=${green} B=${blue}"
return zwaveSecureEncap(endpointCommand)
}

private List ledLevelCommands(Number level) {
// Switch Multilevel accepts 0-99 (100-254 are reserved), so 100% maps to 99.
int pct = Math.max(0, Math.min(LED_MAX_LEVEL, level.intValue()))
if(pct > 0) state.ledLastLevel = pct
return [endpointCmd(zwave.switchMultilevelV2.switchMultilevelSet(value:pct, dimmingDuration:0)),
endpointCmd(zwave.switchMultilevelV2.switchMultilevelGet())]
}
def setLedLevel(Number level) {
log.info "${device.displayName}: LED brightness requested ${level}"
sendCommands(ledLevelCommands(level))
}
def setLedOn() {
Integer last = safeInt(state.ledLastLevel)
setLedLevel((last != null && last > 0) ? last : LED_MAX_LEVEL)
}
def setLedOff() { setLedLevel(0) }

// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------

def parse(String description) {
if(debugLogging) log.debug "${device.displayName} raw: ${description}"
if(description?.trim()?.startsWith('{')) {
try {
Map report = new JsonSlurper().parseText(description) as Map
handleJsReport(report)
} catch(Exception e) { log.warn "${device.displayName}: Z-Wave-JS JSON parse error: ${e.message}" }
return
}
try {
def cmd = zwave.parse(description, [0x20:1, 0x25:1, 0x26:2, 0x32:4, 0x33:3, 0x60:4, 0x70:2, 0x6C:1, 0x86:2, 0x98:1, 0x9F:1])
if(cmd) zwaveEvent(cmd, 0)
} catch(Exception e) { log.warn "${device.displayName}: legacy parse error: ${e.message}" }
}

private void handleJsReport(Map report) {
Integer cc = safeInt(report.cc)
Integer ep = safeInt(report.ep) ?: 0
List values = (report.values instanceof List) ? report.values : []
if(debugLogging && !values) log.debug "${device.displayName}: JSON report without values: ${report}"
values.each { entry ->
if(!(entry instanceof Map)) return
Integer entryCc = safeInt(entry.commandClass) ?: cc
Integer endpoint = safeInt(entry.endpoint) ?: ep
def raw = entry.containsKey('newValue') ? entry.newValue : entry.value
String prop = entry.propertyName?.toString() ?: entry.property?.toString() ?: ''
if(debugLogging) log.debug "${device.displayName}: report cc=${entryCc} ep=${endpoint} property=${prop} value=${raw}"
// Only actual state counts; ignore targetValue, duration and similar properties.
boolean isCurrent = (prop == '' || prop == 'currentValue')
if(entryCc == 37 && endpoint in [0,1] && raw != null && isCurrent) {
String sw = (raw == true || raw.toString().equalsIgnoreCase('true') || raw.toString() == '255' || raw.toString() == '1') ? 'on' : 'off'
sendEvent(name:'switch', value:sw, descriptionText:"${device.displayName} is ${sw}")
} else if(entryCc == 112) {
handleJsConfiguration(entry, raw, endpoint)
} else if(entryCc == 50) {
handleJsMeter(entry, raw, endpoint)
} else if(entryCc == 38 && endpoint == 2 && raw instanceof Number && isCurrent) {
recordLedLevel((raw as Number).intValue())
} else if(entryCc == 51 && endpoint == 2 && (prop == '' || prop.startsWith('currentColor'))) {
handleJsLedColor(entry, raw)
}
}
}

private void handleJsConfiguration(Map entry, def raw, Integer endpoint) {
// Some Z-Wave-JS ConfigurationGet responses use {request:{args:[1]},response:2}
// inside the values array instead of a normal property/value event.
if(entry.request instanceof Map && entry.containsKey('response')) {
def request = entry.request as Map
Integer queried = (request.args instanceof List && request.args) ? safeInt(request.args[0]) : null
if(queried != null) recordConfigurationParameter(queried, entry.response)
else if(debugLogging) log.debug "${device.displayName}: configuration response with no parameter number: ${entry}"
return
}
def prop = entry.property ?: entry.propertyName
Integer parameter = safeInt(prop)
if(parameter == null) parameter = safeInt(entry.parameterNumber)
if(parameter == null) parameter = safeInt(entry.propertyKey)
if(parameter != null) recordConfigurationParameter(parameter, raw)
else if(debugLogging) log.debug "${device.displayName}: configuration report with no parameter number: ${entry}"
}

private void handleJsLedColor(Map entry, def raw) {
if(debugLogging) log.debug "${device.displayName}: LED color report ${entry}"
String key = (entry.propertyKeyName ?: entry.propertyKey ?: entry.colorComponentId ?: '').toString().toLowerCase()
Integer component = null
if(key.contains('red') || key == '2') component = 2
else if(key.contains('green') || key == '3') component = 3
else if(key.contains('blue') || key == '4') component = 4
if(component == null) {
if(debugLogging) log.debug "${device.displayName}: unmatched LED color report ${entry}"
return
}
Integer value = safeInt(raw)
if(value == null) return
recordLedColorComponent(component, value)
}

private Integer safeInt(def n) {
try { return n == null ? null : Integer.decode(n.toString()) } catch(Exception ignored) { return null }
}

private void handleJsMeter(Map entry, def raw, Integer endpoint) {
if(!(raw instanceof Number)) {
if(debugLogging) log.debug "${device.displayName}: meter value not numeric: ${entry}"
return
}
// Z-Wave-JS Meter propertyKey is a composite identifier, not the scale.
// The authoritative scale is metadata.ccSpecific.scale (0=kWh, 2=W, 4=V, 5=A).
Integer scale = safeInt(entry.metadata?.ccSpecific?.scale)
String attr = null
String unit = null
switch(scale) {
case 0: attr='energy'; unit='kWh'; break
case 2: attr='power'; unit='W'; break
case 4: attr='voltage'; unit='V'; break
case 5: attr='amperage'; unit='A'; break
}
// Fall back to exact unit metadata only when the scale is absent.
if(scale == null) {
switch(entry.metadata?.unit?.toString()) {
case 'kWh': attr='energy'; unit='kWh'; break
case 'W': attr='power'; unit='W'; break
case 'V': attr='voltage'; unit='V'; break
case 'A': attr='amperage'; unit='A'; break
}
}
if(attr) {
sendEvent(name:attr, value:raw, unit:unit)
if(descriptionLogging) log.info "${device.displayName}: ${attr} is ${raw} ${unit}"
} else log.warn "${device.displayName}: unrecognized meter scale/property: ${entry}"
}

def zwaveEvent(hubitat.zwave.commands.securityv1.SecurityMessageEncapsulation cmd, Integer ep) {
def inner = cmd.encapsulatedCommand([0x20:1, 0x25:1, 0x26:2, 0x32:4, 0x33:1, 0x60:3, 0x70:2, 0x6C:1])
if(inner) zwaveEvent(inner, ep)
}
def zwaveEvent(hubitat.zwave.commands.multichannelv4.MultiChannelCmdEncap cmd, Integer ignored) {
def inner = cmd.encapsulatedCommand([0x20:1, 0x25:1, 0x26:2, 0x32:4, 0x33:3, 0x70:2, 0x6C:1])
if(inner) zwaveEvent(inner, cmd.sourceEndPoint as Integer)
}
def zwaveEvent(hubitat.zwave.commands.multichannelv3.MultiChannelCmdEncap cmd, Integer ignored) {
def inner = cmd.encapsulatedCommand([0x20:1, 0x25:1, 0x26:2, 0x32:4, 0x33:1, 0x70:2, 0x6C:1])
if(inner) zwaveEvent(inner, cmd.sourceEndPoint as Integer)
}
// Handle the encapsulated report, then confirm receipt so the device does not retransmit.
def zwaveEvent(hubitat.zwave.commands.supervisionv1.SupervisionGet cmd, Integer ep) {
def inner = cmd.encapsulatedCommand([0x20:1, 0x25:1, 0x26:2, 0x32:4, 0x33:1, 0x60:3, 0x70:2])
if(inner) zwaveEvent(inner, ep)
def report = zwave.supervisionV1.supervisionReport(sessionID:cmd.sessionID, reserved:0, moreStatusUpdates:false, status:0xFF, duration:0)
sendHubCommand(new hubitat.device.HubAction(ep ? endpointCmd(report, ep) : secureCmd(report), hubitat.device.Protocol.ZWAVE))
}
def zwaveEvent(hubitat.zwave.commands.switchbinaryv1.SwitchBinaryReport cmd, Integer ep) {
if(ep == 0 || ep == 1) {
String val = cmd.value ? 'on' : 'off'
sendEvent(name:'switch', value:val, descriptionText:"${device.displayName} is ${val}")
}
}
def zwaveEvent(hubitat.zwave.commands.basicv1.BasicReport cmd, Integer ep) {
if(ep == 0 || ep == 1) sendEvent(name:'switch', value:cmd.value ? 'on':'off')
}
def zwaveEvent(hubitat.zwave.commands.configurationv2.ConfigurationReport cmd, Integer ep) {
if(cmd.configurationValue instanceof List && cmd.configurationValue) {
// Documented ZWA023 parameters are unsigned (e.g. 135 V, 600 s), so decode unsigned.
long v = 0
cmd.configurationValue.each { b -> v = (v << 8) | ((b as int) & 0xFF) }
recordConfigurationParameter(cmd.parameterNumber as Integer, v)
}
}
def zwaveEvent(hubitat.zwave.commands.meterv4.MeterReport cmd, Integer ep) {
if(debugLogging) log.debug "Meter report: ${cmd}"
if(cmd.meterType != 1) return
switch(cmd.scale as Integer) {
case 0: sendEvent(name:'energy', value:cmd.scaledMeterValue, unit:'kWh'); break
case 2: sendEvent(name:'power', value:cmd.scaledMeterValue, unit:'W'); break
case 4: sendEvent(name:'voltage', value:cmd.scaledMeterValue, unit:'V'); break
case 5: sendEvent(name:'amperage', value:cmd.scaledMeterValue, unit:'A'); break
}
}
def zwaveEvent(hubitat.zwave.commands.switchmultilevelv2.SwitchMultilevelReport cmd, Integer ep) {
if(ep == 2) recordLedLevel(cmd.value as int)
}
private void recordLedLevel(int level) {
if(level > 0 && level <= LED_MAX_LEVEL) state.ledLastLevel = level
ledEvent('level', level, '%')
ledEvent('switch', level > 0 ? 'on' : 'off')
}
def zwaveEvent(hubitat.zwave.commands.switchcolorv1.SwitchColorReport cmd, Integer ep) {
if(ep != 2) return
recordLedColorComponent(cmd.colorComponent as int, cmd.value as int)
}
def zwaveEvent(hubitat.zwave.commands.switchcolorv3.SwitchColorReport cmd, Integer ep) {
if(ep != 2) return
recordLedColorComponent(cmd.colorComponentId as int, cmd.value as int)
}
private void recordLedColorComponent(int component, int value) {
if(!(component in [2,3,4])) return
state["ledRgb${component}"] = Math.max(0, Math.min(255, value))
if(![2,3,4].every { state["ledRgb${it}"] != null }) return
List rgb = [state.ledRgb2 as int, state.ledRgb3 as int, state.ledRgb4 as int]
def hsv = hubitat.helper.ColorUtils.rgbToHSV(rgb)
ledEvent('hue', hsv[0])
ledEvent('saturation', hsv[1])
// Color Switch RGB describes color; level comes from the Switch Multilevel report.
String hex = String.format('#%02X%02X%02X', rgb[0], rgb[1], rgb[2])
ledEvent('color', hex)
sendEvent(name:'ledRgbReported', value:rgb.join(','))
String colorName = rgb.every { it == 0 } ? 'Black' :
(rgb[0] > rgb[1]*2 && rgb[0] > rgb[2]*2) ? 'Red' :
(rgb[1] > rgb[0]*2 && rgb[1] > rgb[2]*2) ? 'Green' :
(rgb[2] > rgb[0]*2 && rgb[2] > rgb[1]*2) ? 'Blue' :
(rgb[0] > 200 && rgb[1] > 200 && rgb[2] > 200) ? 'White' : hex
ledEvent('colorName', colorName)
if(debugLogging) log.debug "${device.displayName}: LED RGB=${rgb} HSV=${hsv}, color=${hex}"
}

def zwaveEvent(hubitat.zwave.Command cmd, Integer ep) {
if(debugLogging) log.debug "Unhandled endpoint ${ep}: ${cmd}"
}

The child drivers is:

/* Aeotec Smart Switch 7 US LED child - experimental v1.1
 * v1.1: hue/saturation changes no longer resend brightness; removed unused receiveParentEvent.
 * Attributes are published by the parent driver.
 */
metadata {
    definition(name:'Aeotec Smart Switch 7 US - custom driver - LED', namespace:'custom.aeotec', author:'ChatGPT') {
        capability 'Actuator'
        capability 'Switch'
        capability 'SwitchLevel'
        capability 'ColorControl'
        capability 'Refresh'
    }
}
def installed() { log.info "${device.displayName}: LED child installed" }
def updated() { }
def parse(String description) { }
def on() { parent.setLedOn() }
def off() { parent.setLedOff() }
def setLevel(Number level) { parent.setLedLevel(level) }
def setLevel(Number level, Number duration) { parent.setLedLevel(level) }
def setColor(Map color) { parent.setLedColor(color) }
def setHue(Number hue) { parent.setLedColor([hue:hue, saturation:device.currentValue('saturation') ?: 100]) }
def setSaturation(Number saturation) { parent.setLedColor([hue:device.currentValue('hue') ?: 0, saturation:saturation]) }
def refresh() { parent.refreshLed() }