Ikea Symfonisk remote! On French Ikea. Zigbee!

OK, this is why I freaking hate Ikea devices...
Writing a driver for this thing should have been a one hour, maybe two hour job tops.

so here we go...
Join, nice!
Broadcast messaging disabled when clusters are bound to?, yup, nice!
Payload decoding?, simple and done...
Duplicate frames sent for every command 15ms apart?, WTH...

OK, I get wanting to make sure the frame gets to the hub over the customers potentially crap Zigbee mesh, but that's why there's such a thing in Zigbee world as a command acknowledgment request, but no we won't request one of those because then we would have to wait for the response and send the command after some timeout if we don't get a response from the hub, and that sounds like way too much work, so lets just send the exact same freaking frame twice...

It takes HE about 25ms to commit an event to the db, so the net effect of this issue is this thing would always fire off two events for everything, which could wreck all sorts of havoc...

So back in the box and on the shelf it goes...

2 Likes

Have to say Mike, I knew exactly what was going to happen when I saw the 4 duplicate messages in the log lol. Thanks a bunch for taking a look though, I assumed that would be too much of a pain to code around when I saw that. IKEA really have made some odd choices with their zigbee stuff.

They do work ok as a spammy touchlink control directly to a bulb though, so I'll use em as dirty quick dimmer for side lamps.

Thanks mate.

2 Likes

That's just it, it can't be coded around currently, we would have to intentionally slow down fetching frames from our zigbee receive queue by around 30ms so these could be detected before being sent to the driver, and we're not about to do that...

Don't blame ya lol. The coding on the tradfri gateway must be a right royal mess.

I suspect they don't care, just send it to the device twice, for the same lame reason the controller sends it twice...

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

Nice work. Is that info something that might help @mike.maxwell with other troublesome IKEA devices?

The other problematic Ikea devices don't produce double reports, so this won't help with them.
Based on what @cybrmage has discovered I'll give this device another shot.
This is completly aberrant behavior by the way...

2 Likes

Maybe Sonos helped with this one. I bought one to play with and it’s much smoother than the puck dimmer at controlling bulbs when you pair it via touch link.

Yes, yes it is...

Wait!!! Are we talking about me or the device???

8-}

Doubtful, what does sonos know of zigbee?

1 Like

Aparently that's not the criteria to produce Zigbee products for IKEA. :wink:

This works very well for Hue lights. Extremely smooth dimming if you use buttons 4 & 5 for start raising/lowering and 6 for stop changing. Have not tried with Sonos yet, but with this DTH and your changes, it is one of my new favourite dimmers. Nothing like the experience with the IKEA puck dimmer. @bertabcd1234, I wonder if this same method would work with the puck?

Thanks for this. I did find that it was necessary to be very patient for the pairing to complete, and then I had to pair it twice to get the battery level to appear, and a few moments later for the pushed state to appear. I tested the pairing on two different hubs, with the latest and a previous platform build, and it acted the same on each.

I was planning to return this, but now I really like it. I don't have a Lutron Aurora dimmer, but I have just ordered one to compare.

Thanks again. Much appreciated!

How have you set up the rules to control your lights using the controller?

Button controller. I’m still using an older platform version for my primary hub, so this example is button controller 3, but the principal is the same for RM4

And why the heck does Australia get black controllers and we don't? :rage:

Works great with @stephack 's ABC to control my Symfonisk/Sonos. Had to use button controller for previous track (maybe that can be added to ABC?). Now if smooth volume control (start raising, start lowering, stop changing) could be added, this would be the wireless equivalent to turning the volume dial on a traditional amplifier.

Here’s a method for smooth volume control with the Symfonisk sound controller

1 Like

A little late, but that is almost exactly what SmartThings does with hacky devices and it annoys the hell out of me (I got this confirmed by a couple of ST engineers when Samsung acquired them).

1 Like

Compared to the Lutron Aurora, the Symfonisk sound controller is just as smooth at dimming, but not as responsive due to the fixed speed of the start increase/decrease dimmer level built into RM. I found the Aurora sometimes refused to turn off with the HE driver, and would turn back on immediately, finally turning off after a few tries. This does not happen when paired to the Hue Bridge.

Overall I find the Symfonisk controller to be more flexible due to the way @cybrmage has modified the driver with the 3 button actions based on rotation direction or lack thereof. It also has up to 3 button presses available by clicking, versus only 1 click with the Lutron Aurora.

While I do like the Lutron Aurora, and its dial feels very smooth when rotating, the rest of it feels surprisingly cheap and unfinished. Not at all what I expected for a dimmer/button that is $57 CAD, versus $15 CAD for the Symfonisk sound controller, which feels much better made.

1 Like