Ikea Symfonisk remote! On French Ikea. Zigbee!

I have found a "work around" for the issue...

Once the device is paired to the hub, it does not report events to the hub until the hub binds to it. If a driver binds to the zigbee.ON_OFF_CLUSTER or zigbee.LEVEL_CONTROL_CLUSTER, the device will send two reports for every press/twist of the device... BUT If the driver binds to the zigbee.IAS_ZONE_CLUSTER, the device then sends single reports for the events.

I have created a driver, based off an ST DTH, that exposes the device as a 6 button device...

The controller does not support "hold" events, but does support:

  • Single Tap - mapped to Button 1 pushed
  • Double Tap - mapped to Button 2 pushed
  • Triple Tap - mapped to Button 3 pushed
  • rotate clockwise - mapped to Button 4 pushed
  • rotate counter-clockwise - mapped to Button 5 pushed
  • rotate stopped - mapped to button 6 pushed

Additionally, the rotate CW/CCW are also mapped to level events, where the value is a proportional representation of the rotation from -100(CCW) to 100(CW) - I do not know if this will be useful, it is a holdover from the original ST DTH.

/**
 *  Copyright 2019 Juha Tanskanen
 *
 *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 *  in compliance with the License. You may obtain a copy of the License at:
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *  for the specific language governing permissions and limitations under the License.
 *
 *  Sonos Speaker Control
 *
 *  Version Author              Note
 *  0.9     Juha Tanskanen      Initial release
 *  0.9HE   CybrMage            Hubitat version Initial release
 *
 */

import hubitat.zigbee.zcl.DataType

metadata {
    definition (name: "SYMFONISK Sound Controller", namespace: "cybr", author: "Juha Tanskanen / CybrMage") {
        capability "Actuator"
        capability "Battery"
        capability "PushableButton"
        capability "Configuration"

        fingerprint inClusters: "0000, 0001, 0003, 0020, 1000", outClusters: "0003, 0004, 0006, 0008, 0019, 1000", manufacturer: "IKEA of Sweden", model: "SYMFONISK Sound Controller"
		
    }

	preferences {
		input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
	}

}

private getVERSION() { "v0.9HE" }

private getCLUSTER_GROUPS() { 0x0004 }

private getIkeaSoundControlNames() {
    [
        "singleTap",	// "single tap Control button",
		"doubleTap",	// "double tap Control button"
        "tripleTap",	// "triple tap Control button"
        "levelUp",		// "increase volume knob"
        "levelDown",	// "decrease volume button"
        "levelStop",	// "increase volume button"
    ]
}

private getDeviceInfoAttributeName() {
	[
		"ZCLVersion",
		"ApplicationVersion",
		"StackVersion",
		"HWVersion",
		"ManufacturerName",
		"ModelIdentifier",
		"DateCode",
		"PowerSource",
	]
}

def INFO(String msg) { if (logEnable) log.info(msg)}
def DEBUG(String msg) { if (logEnable) log.debug(msg)}

//def getZCLDataValue(String Encoding, String Value) {
//	switch(Encoding) {
//		case 0x08:
//		case 0x09:
//		case 0x0a:
//		case 0x0b:
//		case 0x0c:
//		case 0x0d:
//		case 0x0e:
//		case 0x0f:
//		case 0x20:
//			return Value as Integer
//			break
//		case 0x10:
//			return (Value as Integer)? true : false
//			break
//		case 0x42:
//			return new String(hubitat.helper.HexUtils.hexStringToByteArray(Value.substring(2)))
//			break
//	}
//}

// parse events into attributes
def parse(String description) {
    DEBUG("Parsing message from device: '$description'")

    def event = ""
    try {
        event = zigbee.getEvent(description)
    } catch(e) {}
    if (event) {
        DEBUG("  Creating event: ${event}")
        sendEvent(event)
    } else {
        if ((description?.startsWith("catchall:")) || (description?.startsWith("read attr -"))) {
            //DEBUG("  Catch all: $description")
            def descMap = zigbee.parseDescriptionAsMap(description)
			if (descMap.clusterInt == 0 && descMap.command == "01") {
//				DEBUG("  Received Basic Device Info Response - ${DeviceInfoAttributeName[(descMap.attrId as Integer)]} = ${getZCLDataValue(descMap.encoding, descMap.value)}")
				DEBUG("  Received Basic Device Info Response")
			} else if (descMap.clusterInt == 1 && descMap.command == "07") {
				DEBUG("  Received Configure Reporting Response")
			} else if (descMap.clusterInt == zigbee.IDENTIFY_CLUSTER && descMap.command == "01") {
				DEBUG("  Received IDENTIFY report")
			} else if (descMap.clusterInt == 6 && descMap.command == "00") {
				DEBUG("  Received Descriptor Match Request")
			} else if (descMap.clusterInt == 0x13 && descMap.command == "00" && descMap.profileId == "0000") {
					def data = descMap.data
					def id16 = "${data[2]}${data[1]}"
					def id64 = "${data[10]}${data[9]}${data[8]}${data[7]}${data[6]}${data[5]}${data[4]}${data[3]}"
					def caps = "${data[11]}"
					DEBUG("  Received DEVICE DISCOVERY BROADCAST: ${id16}  ${id64}  ${caps}")
			} else if (descMap.clusterInt == 0x8005) {
				DEBUG("  Received Active endpoints response")
			} else if (descMap.clusterInt == 0x8021) {
				DEBUG("  Received BIND RESPONSE")
			} else if (descMap.clusterInt == zigbee.POWER_CONFIGURATION_CLUSTER && descMap.attrId == "0021") {
				DEBUG("  Processing Battery Event")
				event = getBatteryEvent(zigbee.convertHexToInt(descMap.value))
			} else if (descMap.clusterInt == CLUSTER_SCENES || descMap.clusterInt == zigbee.ON_OFF_CLUSTER || descMap.clusterInt == zigbee.LEVEL_CONTROL_CLUSTER) {
				DEBUG("  Received button or volume Event")
				event = getButtonEvent(descMap)
			} else {
				DEBUG("  UNHANDLED REPORT: \ndescription: ${description}\ndescMap: ${descMap}")
			}
        }

        def result = []
        if (event) {
            DEBUG("  Creating event: ${event}")
            result = createEvent(event)
        }

        return result
    }
}

def installed() {
    log.debug "installed() called - Driver ${VERSION}"
    sendEvent(name: "numberOfButtons", value: 6, isStateChange: true)
    state.start = now()
}

def updated() {
	log.debug "Updated() called - Driver ${VERSION}"
    sendEvent(name: "numberOfButtons", value: 6, isStateChange: true)
}

def configure() {
    log.debug "Configuring device ${device.getDataValue("model")} - Driver ${VERSION}"
    sendEvent(name: "numberOfButtons", value: 6, isStateChange: true)
	state.lastButtonEvent = 0
	
	def cmds = zigbee.configureReporting(zigbee.POWER_CONFIGURATION_CLUSTER, 0x21, DataType.UINT8, 30, 21600, 0x01) +
				zigbee.readAttribute(zigbee.POWER_CONFIGURATION_CLUSTER, 0x21) +
		["zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0x500 {${device.zigbeeId}} {}"] +
//		["zdo bind 0x${device.deviceNetworkId} 0x01 0x01 6 {${device.zigbeeId}} {}"] +
//		["zdo bind 0x${device.deviceNetworkId} 0x01 0x01 8 {${device.zigbeeId}} {}"] +
//				zigbee.enrollResponse() + 
				readDeviceBindingTable() // Need to read the binding table to see what group it's using

    cmds
}

private Map getBatteryEvent(value) {
    def result = [:]
    result.value = value
    result.name = 'battery'
    result.descriptionText = "${device.displayName} battery was ${result.value}%"
    return result
}

private Map getButtonEvent(Map descMap) {
    def buttonState = ""
    def buttonNumber = 0
    Map result = [:]
	DEBUG "  Processing Button Event: Cluster = ${descMap.clusterInt} command: ${descMap.command}"

    if (descMap.clusterInt == zigbee.ON_OFF_CLUSTER) {
        if (descMap.command as Integer == 0x02) {
			buttonState = "pushed"
            buttonNumber = 1
        }
    } else if (descMap.clusterInt == zigbee.LEVEL_CONTROL_CLUSTER) {
        switch (descMap.command as Integer) {
            case 0x02:
                if (descMap.data[0] == "00") {
                    buttonState = "pushed"
		            buttonNumber = 2
                } else {
                    buttonState = "pushed"
		            buttonNumber = 3
                }
                //buttonNumber = REMOTE_BUTTONS.CONTROL_BUTTON
                break;
            case 0x01:
                if (descMap.data[0] == "00") {
                    buttonState = "levelUp"
		            buttonNumber = 4
                } else {
                    buttonState = "levelDown"
		            buttonNumber = 5
                }
                break;
            case 0x03:
                buttonState = "levelStop"
		        buttonNumber = 6
                break;
            default:
                break;
        }
    }

    if (buttonNumber != 0) {
		DEBUG("  Button - STATE: ${buttonState}  NUMBER: ${buttonNumber}")

        // Create and send component event
        sendButtonEvent(buttonNumber, buttonState)
        sendLevelEvent(buttonNumber, buttonState)
    }
    result
}

private sendButtonEvent(buttonNumber, buttonState) {
	if (buttonNumber > 0) {
		def descriptionText = "$device.displayName button ${buttonNumber} (${getButtonName(buttonNumber)}) was pushed" // TODO: Verify if this is needed, and if capability template already has it handled
		sendEvent(name: "pushed", value: buttonNumber, descriptionText: descriptionText, isStateChange: true)
		state.lastButtonEvent = now()
		INFO("  sendButtonEvent: sendEvent(name: \"pushed\", value: ${buttonNumber}, descriptionText: \"${descriptionText}\", isStateChange: true)")
	}
}

private sendLevelEvent(buttonNumber, buttonState) {

    if (buttonNumber > 3) {
        switch (buttonState) {
            case "levelUp":
                state.start = now()
                state.direction = 1
                break
            case "levelDown":
                state.start = now()
                state.direction = 0
                break
            case "levelStop":
                long iTime = now() - state.start
                def iChange = 0

                // Ignore turns over 5 seconds, probably a lag issue
                if (iTime > 5000) {
                    iTime = 0
                }

                // Change based on 5 seconds for full 0-100 change in brightness
                iChange = iTime/5000 * 100
                def volumeChange = state.direction ? ((BigInteger)iChange).intValue() : ((BigInteger)(0 - iChange)).intValue()
                def descriptionText = "Switch Level was changed $volumeChange"
                sendEvent(name: "level", value: volumeChange, descriptionText: descriptionText, isStateChange: true)
				INFO("  sendLevelEvent: sendEvent(name: \"level\", value: \"${volumeChange}\", descriptionText: \"${descriptionText}\", isStateChange: true)")
                break
        }
    }
}

private getButtonLabel(buttonNum) {
    def label = ikeaSoundControlNames[buttonNum - 1]
	return label
}

private getButtonName(buttonNum) {
//    return "${device.displayName} " + getButtonLabel(buttonNum)
    return getButtonLabel(buttonNum)
}

private List addHubToGroup(Integer groupAddr) {
    ["he cmd 0x0000 0x01 ${CLUSTER_GROUPS} 0x00 {${zigbee.swapEndianHex(zigbee.convertToHexString(groupAddr,4))} 00}", "delay 200"]
}

private List readDeviceBindingTable() {
    log.info "readDeviceBindingTable called..."
    ["zdo mgmt-bind 0x${device.deviceNetworkId} 0", "delay 200"] +
	["zdo active 0x${device.deviceNetworkId}"]
}
6 Likes