├── AlarmThing-Alert.groovy ├── AlarmThing-AlertAll.groovy ├── AlarmThing-AlertContact.groovy ├── AlarmThing-AlertMotion.groovy ├── AlarmThing-AlertSensor.groovy ├── BeaconThings ├── BeaconThing.groovy └── BeaconThingsManager.groovy ├── README.md ├── Sonos.groovy ├── StriimLight ├── StriimLight.groovy └── StriimLightConnect.groovy ├── VirtualButtons ├── AeonMinimote.groovy ├── README.md ├── Securify-KeyFob.groovy ├── SmartenIt-ZBWS3B.groovy ├── VirtualButton.groovy ├── VirtualButtons.groovy └── ZWN-SC7.groovy ├── YouLeftTheDoorOpen.groovy └── garage-switch.groovy /AlarmThing-Alert.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * SmartAlarmAlert 3 | * 4 | * Copyright 2014 ObyCode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "SmartAlarmAlert", 18 | namespace: "com.obycode", 19 | author: "ObyCode", 20 | description: "Alert me when my alarm status changes (armed, alarming, disarmed).", 21 | category: "Safety & Security", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" 24 | ) 25 | 26 | preferences { 27 | section("Alert me when this alarm changes state (arming, armed, disarmed, alarm):") { 28 | input "theAlarm", "capability.alarm", multiple: false, required: true 29 | } 30 | } 31 | 32 | def installed() { 33 | log.debug "Installed with settings: ${settings}" 34 | 35 | initialize() 36 | } 37 | 38 | def updated() { 39 | log.debug "Updated with settings: ${settings}" 40 | 41 | unsubscribe() 42 | initialize() 43 | } 44 | 45 | def initialize() { 46 | log.debug "in initialize" 47 | subscribe(theAlarm, "alarmStatus", statusChanged) 48 | } 49 | 50 | def statusChanged(evt) { 51 | if (evt.value == "away") { 52 | sendPush("Alarm armed to 'away'") 53 | } else if (evt.value == "stay") { 54 | sendPush("Alarm armed to 'stay'") 55 | } else if (evt.value == "arming") { 56 | sendPush("Alarm is arming") 57 | } else if (evt.value == "alarm") { 58 | sendPush("ALARM IS GOING OFF!") 59 | } else if (evt.value == "disarmed") { 60 | sendPush("Alarm is disarmed") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /AlarmThing-AlertAll.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * AlarmThing AlertAll 3 | * 4 | * Copyright 2014 ObyCode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "AlarmThing AlertAll", 18 | namespace: "com.obycode", 19 | author: "ObyCode", 20 | description: "Send a message whenever any sensor changes on the alarm.", 21 | category: "Safety & Security", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" 24 | ) 25 | 26 | preferences { 27 | section("Notify me when there is any activity on this alarm:") { 28 | input "theAlarm", "capability.alarm", multiple: false, required: true 29 | } 30 | } 31 | 32 | def installed() { 33 | log.debug "Installed with settings: ${settings}" 34 | 35 | initialize() 36 | } 37 | 38 | def updated() { 39 | log.debug "Updated with settings: ${settings}" 40 | 41 | unsubscribe() 42 | initialize() 43 | } 44 | 45 | def initialize() { 46 | log.debug "in initialize" 47 | subscribe(theAlarm, "contact", contactTriggered) 48 | subscribe(theAlarm, "motion", motionTriggered) 49 | } 50 | 51 | def contactTriggered(evt) { 52 | if (evt.value == "open") { 53 | sendPush("A door was opened") 54 | } else { 55 | sendPush("A door was closed") 56 | } 57 | } 58 | 59 | def motionTriggered(evt) { 60 | if (evt.value == "active") { 61 | sendPush("Alarm motion detected") 62 | }// else { 63 | //sendPush("Motion stopped") 64 | //} 65 | } 66 | -------------------------------------------------------------------------------- /AlarmThing-AlertContact.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * AlarmThing AlertContact 3 | * 4 | * Copyright 2014 ObyCode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "AlarmThing AlertContact", 18 | namespace: "com.obycode", 19 | author: "ObyCode", 20 | description: "Alert me when a specific contact sensor on my alarm is opened.", 21 | category: "Safety & Security", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" 24 | ) 25 | 26 | preferences { 27 | section("When a contact is opened on this alarm...") { 28 | input "theAlarm", "capability.alarm", multiple: false, required: true 29 | } 30 | section("with this sensor name...") { 31 | input "theSensor", "string", multiple: false, required: true 32 | } 33 | section("push me this message...") { 34 | input "theMessage", "string", multiple: false, required: true 35 | } 36 | } 37 | 38 | def installed() { 39 | log.debug "Installed with settings: ${settings}" 40 | 41 | initialize() 42 | } 43 | 44 | def updated() { 45 | log.debug "Updated with settings: ${settings}" 46 | 47 | unsubscribe() 48 | initialize() 49 | } 50 | 51 | def initialize() { 52 | log.debug "in initialize" 53 | subscribe(theAlarm, theSensor + ".open", sensorTriggered) 54 | } 55 | 56 | def sensorTriggered(evt) { 57 | sendPush(theMessage) 58 | } 59 | -------------------------------------------------------------------------------- /AlarmThing-AlertMotion.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * AlarmThing AlertMotion 3 | * 4 | * Copyright 2014 ObyCode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "AlarmThing AlertMotion", 18 | namespace: "com.obycode", 19 | author: "ObyCode", 20 | description: "Alert me when a specific motion sensor on my alarm is activated.", 21 | category: "Safety & Security", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" 24 | ) 25 | 26 | 27 | preferences { 28 | section("When there is motion on this alarm...") { 29 | input "theAlarm", "capability.alarm", multiple: false, required: true 30 | } 31 | section("for this sensor...") { 32 | input "theSensor", "string", multiple: false, required: true 33 | } 34 | section("push me this message...") { 35 | input "theMessage", "string", multiple: false, required: true 36 | } 37 | } 38 | 39 | def installed() { 40 | log.debug "Installed with settings: ${settings}" 41 | 42 | initialize() 43 | } 44 | 45 | def updated() { 46 | log.debug "Updated with settings: ${settings}" 47 | 48 | unsubscribe() 49 | initialize() 50 | } 51 | 52 | def initialize() { 53 | log.debug "in initialize" 54 | subscribe(theAlarm, theSensor + ".active", sensorTriggered) 55 | } 56 | 57 | def sensorTriggered(evt) { 58 | sendPush(theMessage) 59 | } 60 | -------------------------------------------------------------------------------- /AlarmThing-AlertSensor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * AlarmThing Alert Sensor 3 | * 4 | * Copyright 2014 ObyCode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "AlarmThing Sensor Alert", 18 | namespace: "com.obycode", 19 | author: "ObyCode", 20 | description: "Alert me when there is activity on one or more of my alarm's sensors.", 21 | category: "Safety & Security", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" 24 | ) 25 | 26 | preferences { 27 | page(name: "selectAlarm") 28 | page(name: "selectSensors") 29 | page(name: "selectStates") 30 | } 31 | 32 | def selectAlarm() { 33 | dynamicPage(name: "selectAlarm", title: "Configure Alarm", nextPage:"selectSensors", uninstall: true) { 34 | section("When there is activity on this alarm...") { 35 | input "theAlarm", "capability.alarm", multiple: false, required: true 36 | } 37 | } 38 | } 39 | 40 | def selectSensors() { 41 | dynamicPage(name: "selectSensors", title: "Configure Sensors", uninstall: true, nextPage:"selectStates") { 42 | def sensors = theAlarm.supportedAttributes*.name 43 | if (sensors) { 44 | section("On these sensors...") { 45 | input "theSensors", "enum", required: true, multiple:true, metadata:[values:sensors], refreshAfterSelection:true 46 | } 47 | } 48 | section([mobileOnly:true]) { 49 | label title: "Assign a name", required: false 50 | } 51 | } 52 | } 53 | 54 | def selectStates() { 55 | dynamicPage(name: "selectStates", title: "Which states should trigger a notification?", uninstall: true, install: true) { 56 | theSensors.each() { 57 | def sensor = it 58 | def states = [] 59 | // TODO: Cannot figure out how to get these possible states, so have to guess them based on the current value 60 | switch(theAlarm.currentValue("$it")) { 61 | case "active": 62 | case "inactive": 63 | states = ["active", "inactive"] 64 | break 65 | case "on": 66 | case "off": 67 | states = ["on", "off"] 68 | break 69 | case "detected": 70 | case "clear": 71 | case "tested": 72 | states = ["detected", "clear", "tested"] 73 | break 74 | case "closed": 75 | case "open": 76 | states = ["closed", "open"] 77 | break 78 | default: 79 | log.debug "value not handled: ${theAlarm.currentValue("$sensor")}" 80 | } 81 | if (states) { 82 | section() { 83 | input "${sensor}States", "enum", title:"For $sensor...", required: true, multiple:true, metadata:[values:states], refreshAfterSelection:true 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | def installed() { 91 | log.debug "Installed with settings: ${settings}" 92 | initialize() 93 | } 94 | 95 | def updated() { 96 | log.debug "Updated with settings: ${settings}" 97 | 98 | unsubscribe() 99 | initialize() 100 | } 101 | 102 | def initialize() { 103 | theSensors.each() { 104 | def sensor = it 105 | settings."${it}States".each() { 106 | subscribe(theAlarm, "${sensor}.$it", sensorTriggered) 107 | } 108 | } 109 | } 110 | 111 | def sensorTriggered(evt) { 112 | sendPush("Alarm: ${evt.name} is ${evt.value}") 113 | log.debug "Alarm: ${evt.name} is ${evt.value}" 114 | } 115 | -------------------------------------------------------------------------------- /BeaconThings/BeaconThing.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * BeaconThing 3 | * 4 | * Copyright 2015 obycode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | import groovy.json.JsonSlurper 18 | 19 | metadata { 20 | definition (name: "BeaconThing", namespace: "com.obycode", author: "obycode") { 21 | capability "Beacon" 22 | capability "Presence Sensor" 23 | capability "Sensor" 24 | 25 | attribute "inRange", "json_object" 26 | attribute "inRangeFriendly", "string" 27 | 28 | command "setPresence", ["string"] 29 | command "arrived", ["string"] 30 | command "left", ["string"] 31 | } 32 | 33 | simulator { 34 | status "present": "presence: 1" 35 | status "not present": "presence: 0" 36 | } 37 | 38 | tiles { 39 | standardTile("presence", "device.presence", width: 2, height: 2, canChangeBackground: true) { 40 | state("present", labelIcon:"st.presence.tile.present", backgroundColor:"#53a7c0") 41 | state("not present", labelIcon:"st.presence.tile.not-present", backgroundColor:"#ffffff") 42 | } 43 | valueTile("inRange", "device.inRangeFriendly", inactiveLabel: true, height:1, width:3, decoration: "flat") { 44 | state "default", label:'${currentValue}', backgroundColor:"#ffffff" 45 | } 46 | main "presence" 47 | details (["presence","inRange"]) 48 | } 49 | } 50 | 51 | // parse events into attributes 52 | def parse(String description) { 53 | log.debug "Parsing '${description}'" 54 | } 55 | 56 | def installed() { 57 | sendEvent(name: "presence", value: "not present") 58 | def emptyList = [] 59 | def json = new groovy.json.JsonBuilder(emptyList) 60 | sendEvent(name:"inRange", value:json.toString()) 61 | } 62 | 63 | def setPresence(status) { 64 | log.debug "Status is $status" 65 | sendEvent(name:"presence", value:status) 66 | } 67 | 68 | def arrived(id) { 69 | log.debug "$id has arrived" 70 | def theList = device.latestValue("inRange") 71 | def inRangeList = new JsonSlurper().parseText(theList) 72 | if (inRangeList.contains(id)) { 73 | return 74 | } 75 | inRangeList += id 76 | def json = new groovy.json.JsonBuilder(inRangeList) 77 | log.debug "Now in range: ${json.toString()}" 78 | sendEvent(name:"inRange", value:json.toString()) 79 | 80 | // Generate human friendly string for tile 81 | def friendlyList = "Nearby: " + inRangeList.join(", ") 82 | sendEvent(name:"inRangeFriendly", value:friendlyList) 83 | 84 | if (inRangeList.size() == 1) { 85 | setPresence("present") 86 | } 87 | } 88 | 89 | def left(id) { 90 | log.debug "$id has left" 91 | def theList = device.latestValue("inRange") 92 | def inRangeList = new JsonSlurper().parseText(theList) 93 | inRangeList -= id 94 | def json = new groovy.json.JsonBuilder(inRangeList) 95 | log.debug "Now in range: ${json.toString()}" 96 | sendEvent(name:"inRange", value:json.toString()) 97 | 98 | // Generate human friendly string for tile 99 | def friendlyList = "Nearby: " + inRangeList.join(", ") 100 | 101 | if (inRangeList.empty) { 102 | setPresence("not present") 103 | friendlyList = "No one is nearby" 104 | } 105 | 106 | sendEvent(name:"inRangeFriendly", value:friendlyList) 107 | } 108 | -------------------------------------------------------------------------------- /BeaconThings/BeaconThingsManager.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * BeaconThing Manager 3 | * 4 | * Copyright 2015 obycode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "BeaconThings Manager", 18 | namespace: "com.obycode", 19 | author: "obycode", 20 | description: "SmartApp to interact with the BeaconThings iOS app. Use this app to integrate iBeacons into your smart home.", 21 | category: "Convenience", 22 | iconUrl: "http://beaconthingsapp.com/images/Icon-60.png", 23 | iconX2Url: "http://beaconthingsapp.com/images/Icon-60@2x.png", 24 | iconX3Url: "http://beaconthingsapp.com/images/Icon-60@3x.png", 25 | oauth: true) 26 | 27 | 28 | preferences { 29 | section("Allow BeaconThings to talk to your home") { 30 | 31 | } 32 | } 33 | 34 | def installed() { 35 | log.debug "Installed with settings: ${settings}" 36 | 37 | initialize() 38 | } 39 | 40 | def initialize() { 41 | } 42 | 43 | def uninstalled() { 44 | removeChildDevices(getChildDevices()) 45 | } 46 | 47 | mappings { 48 | path("/beacons") { 49 | action: [ 50 | DELETE: "clearBeacons", 51 | POST: "addBeacon" 52 | ] 53 | } 54 | 55 | path("/beacons/:id") { 56 | action: [ 57 | PUT: "updateBeacon", 58 | DELETE: "deleteBeacon" 59 | ] 60 | } 61 | } 62 | 63 | void clearBeacons() { 64 | removeChildDevices(getChildDevices()) 65 | } 66 | 67 | void addBeacon() { 68 | def beacon = request.JSON?.beacon 69 | if (beacon) { 70 | def beaconId = "BeaconThings" 71 | if (beacon.major) { 72 | beaconId = "$beaconId-${beacon.major}" 73 | if (beacon.minor) { 74 | beaconId = "$beaconId-${beacon.minor}" 75 | } 76 | } 77 | log.debug "adding beacon $beaconId" 78 | def d = addChildDevice("com.obycode", "BeaconThing", beaconId, null, [label:beacon.name, name:"BeaconThing", completedSetup: true]) 79 | log.debug "addChildDevice returned $d" 80 | 81 | if (beacon.present) { 82 | d.arrive(beacon.present) 83 | } 84 | else if (beacon.presence) { 85 | d.setPresence(beacon.presence) 86 | } 87 | } 88 | } 89 | 90 | void updateBeacon() { 91 | log.debug "updating beacon ${params.id}" 92 | def beaconDevice = getChildDevice(params.id) 93 | if (!beaconDevice) { 94 | log.debug "Beacon not found" 95 | return 96 | } 97 | 98 | // This could be just updating the presence 99 | def presence = request.JSON?.presence 100 | if (presence) { 101 | log.debug "Setting ${beaconDevice.label} to $presence" 102 | beaconDevice.setPresence(presence) 103 | } 104 | 105 | // It could be someone arriving 106 | def arrived = request.JSON?.arrived 107 | if (arrived) { 108 | log.debug "$arrived arrived at ${beaconDevice.label}" 109 | beaconDevice.arrived(arrived) 110 | } 111 | 112 | // It could be someone left 113 | def left = request.JSON?.left 114 | if (left) { 115 | log.debug "$left left ${beaconDevice.label}" 116 | beaconDevice.left(left) 117 | } 118 | 119 | // or it could be updating the name 120 | def beacon = request.JSON?.beacon 121 | if (beacon) { 122 | beaconDevice.label = beacon.name 123 | } 124 | } 125 | 126 | void deleteBeacon() { 127 | log.debug "deleting beacon ${params.id}" 128 | deleteChildDevice(params.id) 129 | } 130 | 131 | private removeChildDevices(delete) { 132 | delete.each { 133 | deleteChildDevice(it.deviceNetworkId) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | smartthings-smartapps 2 | ===================== 3 | 4 | A collection of SmartApps I have written to perform various tasks at my house. 5 | 6 | ## Garage Switch 7 | Use a z-wave light switch to control your garage. When the switch is pressed 8 | down, the garage door will close (if its not already), and likewise, it will 9 | open when up is pressed on the switch. Additionally, the indicator light on the 10 | switch will tell you if the garage door is open or closed. 11 | 12 | ## AlarmThing Alert 13 | Alert me when my alarm status changes (armed, alarming, disarmed). 14 | 15 | ## AlarmThing Sensor Alert 16 | Alert me when there is activity on one or more of my alarm's sensors. 17 | NOTE: This app provides a major improvement over those below as the sensors are dynamically listed as options instead of requiring you to type the exact name of the sensor as a string. 18 | 19 | ## AlarmThing AlertMotion 20 | Alert me when a specific motion sensor on my alarm is activated. 21 | 22 | ## AlarmThing AlertContact 23 | Alert me when a specific contact sensor on my alarm is opened. 24 | 25 | ## AlarmThing AlertAll 26 | Send a message whenever any sensor changes on the alarm. 27 | 28 | ## You Left the Door Open 29 | Send a message when a specified door has been left open for too long. 30 | 31 | ## StriimLight Connect 32 | Service manager to find and setup your StriimLight Color bulbs. 33 | 34 | ## StriimLight 35 | Device type for the StriimLight Wifi Color bulbs. This device controls the light bulb (on/off, dimmer level, color). You should also connect to the bulb as a DLNA MediaRenderer to control the music. This has been tested with the StriimLight Wifi Color. 36 | -------------------------------------------------------------------------------- /Sonos.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 SmartThings 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * Sonos Player 14 | * 15 | * Author: SmartThings 16 | * Edited by obycode to add Speech Synthesis capability 17 | */ 18 | 19 | metadata { 20 | definition (name: "Sonos Player", namespace: "smartthings", author: "SmartThings") { 21 | capability "Actuator" 22 | capability "Switch" 23 | capability "Refresh" 24 | capability "Sensor" 25 | capability "Music Player" 26 | capability "Speech Synthesis" 27 | 28 | attribute "model", "string" 29 | attribute "trackUri", "string" 30 | attribute "transportUri", "string" 31 | attribute "trackNumber", "string" 32 | 33 | command "subscribe" 34 | command "getVolume" 35 | command "getCurrentMedia" 36 | command "getCurrentStatus" 37 | command "seek" 38 | command "unsubscribe" 39 | command "setLocalLevel", ["number"] 40 | command "tileSetLevel", ["number"] 41 | command "playTrackAtVolume", ["string","number"] 42 | command "playTrackAndResume", ["string","number","number"] 43 | command "playTextAndResume", ["string","number"] 44 | command "playTrackAndRestore", ["string","number","number"] 45 | command "playTextAndRestore", ["string","number"] 46 | command "playSoundAndTrack", ["string","number","json_object","number"] 47 | command "playTextAndResume", ["string","json_object","number"] 48 | } 49 | 50 | // Main 51 | standardTile("main", "device.status", width: 1, height: 1, canChangeIcon: true) { 52 | state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" 53 | state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#79b821" 54 | state "grouped", label:'Grouped', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 55 | } 56 | 57 | // Row 1 58 | standardTile("nextTrack", "device.status", width: 1, height: 1, decoration: "flat") { 59 | state "next", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff" 60 | } 61 | standardTile("play", "device.status", width: 1, height: 1, decoration: "flat") { 62 | state "default", label:'', action:"music Player.play", icon:"st.sonos.play-btn", nextState:"playing", backgroundColor:"#ffffff" 63 | state "grouped", label:'', action:"music Player.play", icon:"st.sonos.play-btn", backgroundColor:"#ffffff" 64 | } 65 | standardTile("previousTrack", "device.status", width: 1, height: 1, decoration: "flat") { 66 | state "previous", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff" 67 | } 68 | 69 | // Row 2 70 | standardTile("status", "device.status", width: 1, height: 1, decoration: "flat", canChangeIcon: true) { 71 | state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#ffffff" 72 | state "stopped", label:'Stopped', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" 73 | state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" 74 | state "grouped", label:'Grouped', action:"", icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 75 | } 76 | standardTile("pause", "device.status", width: 1, height: 1, decoration: "flat") { 77 | state "default", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", nextState:"paused", backgroundColor:"#ffffff" 78 | state "grouped", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", backgroundColor:"#ffffff" 79 | } 80 | standardTile("mute", "device.mute", inactiveLabel: false, decoration: "flat") { 81 | state "unmuted", label:"", action:"music Player.mute", icon:"st.custom.sonos.unmuted", backgroundColor:"#ffffff", nextState:"muted" 82 | state "muted", label:"", action:"music Player.unmute", icon:"st.custom.sonos.muted", backgroundColor:"#ffffff", nextState:"unmuted" 83 | } 84 | 85 | // Row 3 86 | controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false) { 87 | state "level", action:"tileSetLevel", backgroundColor:"#ffffff" 88 | } 89 | 90 | // Row 4 91 | valueTile("currentSong", "device.trackDescription", inactiveLabel: true, height:1, width:3, decoration: "flat") { 92 | state "default", label:'${currentValue}', backgroundColor:"#ffffff" 93 | } 94 | 95 | // Row 5 96 | standardTile("refresh", "device.status", inactiveLabel: false, decoration: "flat") { 97 | state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh", backgroundColor:"#ffffff" 98 | } 99 | standardTile("model", "device.model", width: 1, height: 1, decoration: "flat") { 100 | state "Sonos PLAY:1", label:'', action:"", icon:"st.sonos.sonos-play1", backgroundColor:"#ffffff" 101 | state "Sonos PLAY:2", label:'', action:"", icon:"st.sonos.sonos-play2", backgroundColor:"#ffffff" 102 | state "Sonos PLAY:3", label:'', action:"", icon:"st.sonos.sonos-play3", backgroundColor:"#ffffff" 103 | state "Sonos PLAY:5", label:'', action:"", icon:"st.sonos.sonos-play5", backgroundColor:"#ffffff" 104 | state "Sonos CONNECT:AMP", label:'', action:"", icon:"st.sonos.sonos-connect-amp", backgroundColor:"#ffffff" 105 | state "Sonos CONNECT", label:'', action:"", icon:"st.sonos.sonos-connect", backgroundColor:"#ffffff" 106 | state "Sonos PLAYBAR", label:'', action:"", icon:"st.sonos.sonos-playbar", backgroundColor:"#ffffff" 107 | } 108 | standardTile("unsubscribe", "device.status", width: 1, height: 1, decoration: "flat") { 109 | state "previous", label:'Unsubscribe', action:"unsubscribe", backgroundColor:"#ffffff" 110 | } 111 | 112 | main "main" 113 | 114 | details([ 115 | "previousTrack","play","nextTrack", 116 | "status","pause","mute", 117 | "levelSliderControl", 118 | "currentSong", 119 | "refresh","model" 120 | //,"unsubscribe" 121 | ]) 122 | } 123 | 124 | // parse events into attributes 125 | def parse(description) { 126 | log.trace "parse('$description')" 127 | def results = [] 128 | try { 129 | 130 | def msg = parseLanMessage(description) 131 | log.info "requestId = $msg.requestId" 132 | if (msg.headers) 133 | { 134 | def hdr = msg.header.split('\n')[0] 135 | if (hdr.size() > 36) { 136 | hdr = hdr[0..35] + "..." 137 | } 138 | 139 | def uuid = "" 140 | def sid = "" 141 | if (msg.headers["SID"]) 142 | { 143 | sid = msg.headers["SID"] 144 | sid -= "uuid:" 145 | sid = sid.trim() 146 | 147 | def pos = sid.lastIndexOf("_") 148 | if (pos > 0) { 149 | uuid = sid[0..pos-1] 150 | log.trace "uuid; $uuid" 151 | } 152 | } 153 | 154 | log.trace "${hdr} ${description.size()} bytes, body = ${msg.body?.size() ?: 0} bytes, sid = ${sid}" 155 | 156 | if (!msg.body) { 157 | if (sid) { 158 | updateSid(sid) 159 | } 160 | } 161 | else if (msg.xml) { 162 | log.trace "has XML body" 163 | 164 | // Process response to getVolume() 165 | def node = msg.xml.Body.GetVolumeResponse 166 | if (node.size()) { 167 | log.trace "Extracting current volume" 168 | sendEvent(name: "level",value: node.CurrentVolume.text()) 169 | } 170 | 171 | // Process response to getCurrentStatus() 172 | node = msg.xml.Body.GetTransportInfoResponse 173 | if (node.size()) { 174 | log.trace "Extracting current status" 175 | def currentStatus = statusText(node.CurrentTransportState.text()) 176 | if (currentStatus) { 177 | if (currentStatus != "TRANSITIONING") { 178 | def coordinator = device.getDataValue('coordinator') 179 | log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'" 180 | updateDataValue('currentStatus', currentStatus) 181 | log.trace "Updated saved status to '$currentStatus'" 182 | 183 | if (coordinator) { 184 | sendEvent(name: "status", value: "grouped", data: [source: 'xml.Body.GetTransportInfoResponse']) 185 | sendEvent(name: "switch", value: "off", displayed: false) 186 | } 187 | else { 188 | log.trace "status = $currentStatus" 189 | sendEvent(name: "status", value: currentStatus, data: [source: 'xml.Body.GetTransportInfoResponse']) 190 | sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false) 191 | } 192 | } 193 | } 194 | } 195 | 196 | // Process group change 197 | node = msg.xml.property.ZoneGroupState 198 | if (node.size()) { 199 | log.trace "Extracting group status" 200 | // Important to use parser rather than slurper for this version of Groovy 201 | def xml1 = new XmlParser().parseText(node.text()) 202 | log.trace "Parsed group xml" 203 | def myNode = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == uuid} 204 | log.trace "myNode: ${myNode}" 205 | 206 | // TODO - myNode is often false and throwing 1000 exceptions/hour, find out why 207 | // https://smartthings.atlassian.net/browse/DVCSMP-793 208 | def myCoordinator = myNode?.parent()?.'@Coordinator' 209 | 210 | log.trace "player: ${myNode?.'@UUID'}, coordinator: ${myCoordinator}" 211 | if (myCoordinator && myCoordinator != myNode.'@UUID') { 212 | // this player is grouped, find the coordinator 213 | 214 | def coordinator = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == myCoordinator} 215 | def coordinatorDni = dniFromUri(coordinator.'@Location') 216 | log.trace "Player has a coordinator: $coordinatorDni" 217 | 218 | updateDataValue("coordinator", coordinatorDni) 219 | updateDataValue("isGroupCoordinator", "") 220 | 221 | def coordinatorDevice = parent.getChildDevice(coordinatorDni) 222 | 223 | // TODO - coordinatorDevice is also sometimes coming up null 224 | if (coordinatorDevice) { 225 | sendEvent(name: "trackDescription", value: "[Grouped with ${coordinatorDevice.displayName}]") 226 | sendEvent(name: "status", value: "grouped", data: [ 227 | coordinator: [displayName: coordinatorDevice.displayName, id: coordinatorDevice.id, deviceNetworkId: coordinatorDevice.deviceNetworkId] 228 | ]) 229 | sendEvent(name: "switch", value: "off", displayed: false) 230 | } 231 | else { 232 | log.warn "Could not find child device for coordinator 'coordinatorDni', devices: ${parent.childDevices*.deviceNetworkId}" 233 | } 234 | } 235 | else if (myNode) { 236 | // Not grouped 237 | updateDataValue("coordinator", "") 238 | updateDataValue("isGroupCoordinator", myNode.parent().ZoneGroupMember.size() > 1 ? "true" : "") 239 | } 240 | // Return a command to read the current status again to take care of status and group events arriving at 241 | // about the same time, but in the reverse order 242 | results << getCurrentStatus() 243 | } 244 | 245 | // Process subscription update 246 | node = msg.xml.property.LastChange 247 | if (node.size()) { 248 | def xml1 = parseXml(node.text()) 249 | 250 | // Play/pause status 251 | def currentStatus = statusText(xml1.InstanceID.TransportState.'@val'.text()) 252 | if (currentStatus) { 253 | if (currentStatus != "TRANSITIONING") { 254 | def coordinator = device.getDataValue('coordinator') 255 | log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'" 256 | updateDataValue('currentStatus', currentStatus) 257 | log.trace "Updated saved status to '$currentStatus'" 258 | 259 | if (coordinator) { 260 | sendEvent(name: "status", value: "grouped", data: [source: 'xml.property.LastChange.InstanceID.TransportState']) 261 | sendEvent(name: "switch", value: "off", displayed: false) 262 | } 263 | else { 264 | log.trace "status = $currentStatus" 265 | sendEvent(name: "status", value: currentStatus, data: [source: 'xml.property.LastChange.InstanceID.TransportState']) 266 | sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false) 267 | } 268 | } 269 | } 270 | 271 | // Volume level 272 | def currentLevel = xml1.InstanceID.Volume.find{it.'@channel' == 'Master'}.'@val'.text() 273 | if (currentLevel) { 274 | log.trace "Has volume: '$currentLevel'" 275 | sendEvent(name: "level", value: currentLevel, description: description) 276 | } 277 | 278 | // Mute status 279 | def currentMute = xml1.InstanceID.Mute.find{it.'@channel' == 'Master'}.'@val'.text() 280 | if (currentMute) { 281 | def value = currentMute == "1" ? "muted" : "unmuted" 282 | log.trace "Has mute: '$currentMute', value: '$value" 283 | sendEvent(name: "mute", value: value, descriptionText: "$device.displayName is $value") 284 | } 285 | 286 | // Track data 287 | def trackUri = xml1.InstanceID.CurrentTrackURI.'@val'.text() 288 | def transportUri = xml1.InstanceID.AVTransportURI.'@val'.text() 289 | def enqueuedUri = xml1.InstanceID.EnqueuedTransportURI.'@val'.text() 290 | def trackNumber = xml1.InstanceID.CurrentTrack.'@val'.text() 291 | 292 | if (trackUri.contains("//s3.amazonaws.com/smartapp-")) { 293 | log.trace "Skipping event generation for sound file $trackUri" 294 | } 295 | else { 296 | def trackMeta = xml1.InstanceID.CurrentTrackMetaData.'@val'.text() 297 | def transportMeta = xml1.InstanceID.AVTransportURIMetaData.'@val'.text() 298 | def enqueuedMeta = xml1.InstanceID.EnqueuedTransportURIMetaData.'@val'.text() 299 | 300 | if (trackMeta || transportMeta) { 301 | def isRadioStation = enqueuedUri.startsWith("x-sonosapi-stream:") 302 | 303 | // Use enqueued metadata, if available, otherwise transport metadata, for station ID 304 | def metaData = enqueuedMeta ? enqueuedMeta : transportMeta 305 | def stationMetaXml = metaData ? parseXml(metaData) : null 306 | 307 | // Use the track metadata for song ID unless it's a radio station 308 | def trackXml = (trackMeta && !isRadioStation) || !stationMetaXml ? parseXml(trackMeta) : stationMetaXml 309 | 310 | // Song properties 311 | def currentName = trackXml.item.title.text() 312 | def currentArtist = trackXml.item.creator.text() 313 | def currentAlbum = trackXml.item.album.text() 314 | def currentTrackDescription = currentName 315 | def descriptionText = "$device.displayName is playing $currentTrackDescription" 316 | if (currentArtist) { 317 | currentTrackDescription += " - $currentArtist" 318 | descriptionText += " by $currentArtist" 319 | } 320 | 321 | // Track Description Event 322 | log.info descriptionText 323 | sendEvent(name: "trackDescription", 324 | value: currentTrackDescription, 325 | descriptionText: descriptionText 326 | ) 327 | 328 | // Have seen cases where there is no engueued or transport metadata. Haven't figured out how to resume in that case 329 | // so not creating a track data event. 330 | // 331 | if (stationMetaXml) { 332 | // Track Data Event 333 | // Use track description for the data event description unless it is a queued song (to support resumption & use in mood music) 334 | def station = (transportUri?.startsWith("x-rincon-queue:") || enqueuedUri?.contains("savedqueues")) ? currentName : stationMetaXml.item.title.text() 335 | 336 | def uri = enqueuedUri ?: transportUri 337 | def previousState = device.currentState("trackData")?.jsonValue 338 | def isDataStateChange = !previousState || (previousState.station != station || previousState.metaData != metaData) 339 | 340 | if (transportUri?.startsWith("x-rincon-queue:")) { 341 | updateDataValue("queueUri", transportUri) 342 | } 343 | 344 | def trackDataValue = [ 345 | station: station, 346 | name: currentName, 347 | artist: currentArtist, 348 | album: currentAlbum, 349 | trackNumber: trackNumber, 350 | status: currentStatus, 351 | level: currentLevel, 352 | uri: uri, 353 | trackUri: trackUri, 354 | transportUri: transportUri, 355 | enqueuedUri: enqueuedUri, 356 | metaData: metaData, 357 | ] 358 | 359 | if (trackMeta != metaData) { 360 | trackDataValue.trackMetaData = trackMeta 361 | } 362 | 363 | results << createEvent(name: "trackData", 364 | value: trackDataValue.encodeAsJSON(), 365 | descriptionText: currentDescription, 366 | displayed: false, 367 | isStateChange: isDataStateChange 368 | ) 369 | } 370 | } 371 | } 372 | } 373 | if (!results) { 374 | def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') 375 | .replaceAll('()','\n$1\n') 376 | .replaceAll('\n\n','\n').encodeAsHTML() : "" 377 | results << createEvent( 378 | name: "sonosMessage", 379 | value: "${msg.body.encodeAsMD5()}", 380 | description: description, 381 | descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", 382 | data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", 383 | isStateChange: false, displayed: false) 384 | } 385 | } 386 | else { 387 | log.warn "Body not XML" 388 | def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') 389 | .replaceAll('()','\n$1\n') 390 | .replaceAll('\n\n','\n').encodeAsHTML() : "" 391 | results << createEvent( 392 | name: "unknownMessage", 393 | value: "${msg.body.encodeAsMD5()}", 394 | description: description, 395 | descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", 396 | data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", 397 | isStateChange: true, displayed: true) 398 | } 399 | } 400 | //log.trace "/parse()" 401 | } 402 | catch (Throwable t) { 403 | //results << createEvent(name: "parseError", value: "$t") 404 | sendEvent(name: "parseError", value: "$t", description: description) 405 | throw t 406 | } 407 | results 408 | } 409 | 410 | def installed() { 411 | def result = [delayAction(5000)] 412 | result << refresh() 413 | result.flatten() 414 | } 415 | 416 | def on(){ 417 | play() 418 | } 419 | 420 | def off(){ 421 | stop() 422 | } 423 | 424 | def setModel(String model) 425 | { 426 | log.trace "setModel to $model" 427 | sendEvent(name:"model",value:model,isStateChange:true) 428 | } 429 | 430 | def refresh() { 431 | log.trace "refresh()" 432 | def result = subscribe() 433 | result << getCurrentStatus() 434 | result << getVolume() 435 | result.flatten() 436 | } 437 | 438 | // For use by apps, sets all levels if sent to a non-coordinator in a group 439 | def setLevel(val) 440 | { 441 | log.trace "setLevel($val)" 442 | coordinate({ 443 | setOtherLevels(val) 444 | setLocalLevel(val) 445 | }, { 446 | it.setLevel(val) 447 | }) 448 | } 449 | 450 | // For use by tiles, sets all levels if a coordinator, otherwise sets only the local one 451 | def tileSetLevel(val) 452 | { 453 | log.trace "tileSetLevel($val)" 454 | coordinate({ 455 | setOtherLevels(val) 456 | setLocalLevel(val) 457 | }, { 458 | setLocalLevel(val) 459 | }) 460 | } 461 | 462 | // Always sets only this level 463 | def setLocalLevel(val, delay=0) { 464 | log.trace "setLocalLevel($val)" 465 | def v = Math.max(Math.min(Math.round(val), 100), 0) 466 | log.trace "volume = $v" 467 | 468 | def result = [] 469 | if (delay) { 470 | result << delayAction(delay) 471 | } 472 | result << sonosAction("SetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredVolume: v]) 473 | //result << delayAction(500), 474 | result << sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"]) 475 | result 476 | } 477 | 478 | private setOtherLevels(val, delay=0) { 479 | log.trace "setOtherLevels($val)" 480 | if (device.getDataValue('isGroupCoordinator')) { 481 | log.trace "setting levels of coordinated players" 482 | def previousMaster = device.currentState("level")?.integerValue 483 | parent.getChildDevices().each {child -> 484 | if (child.getDeviceDataByName("coordinator") == device.deviceNetworkId) { 485 | def newLevel = childLevel(previousMaster, val, child.currentState("level")?.integerValue) 486 | log.trace "Setting level of $child.displayName to $newLevel" 487 | child.setLocalLevel(newLevel, delay) 488 | } 489 | } 490 | } 491 | log.trace "/setOtherLevels()" 492 | } 493 | 494 | private childLevel(previousMaster, newMaster, previousChild) 495 | { 496 | if (previousMaster) { 497 | if (previousChild) { 498 | Math.round(previousChild * (newMaster / previousMaster)) 499 | } 500 | else { 501 | newMaster 502 | } 503 | } 504 | else { 505 | newMaster 506 | } 507 | } 508 | 509 | def getGroupStatus() { 510 | def result = coordinate({device.currentValue("status")}, {it.currentValue("status")}) 511 | log.trace "getGroupStatus(), result=$result" 512 | result 513 | } 514 | 515 | def play() { 516 | log.trace "play()" 517 | coordinate({sonosAction("Play")}, {it.play()}) 518 | } 519 | 520 | def stop() { 521 | log.trace "stop()" 522 | coordinate({sonosAction("Stop")}, {it.stop()}) 523 | } 524 | 525 | def pause() { 526 | log.trace "pause()" 527 | coordinate({sonosAction("Pause")}, {it.pause()}) 528 | } 529 | 530 | def nextTrack() { 531 | log.trace "nextTrack()" 532 | coordinate({sonosAction("Next")}, {it.nextTrack()}) 533 | } 534 | 535 | def previousTrack() { 536 | log.trace "previousTrack()" 537 | coordinate({sonosAction("Previous")}, {it.previousTrack()}) 538 | } 539 | 540 | def seek(trackNumber) { 541 | log.trace "seek($trackNumber)" 542 | coordinate({sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: trackNumber])}, {it.seek(trackNumber)}) 543 | } 544 | 545 | def mute() 546 | { 547 | log.trace "mute($m)" 548 | // TODO - handle like volume? 549 | //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1])}, {it.mute()}) 550 | sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1]) 551 | } 552 | 553 | def unmute() 554 | { 555 | log.trace "mute($m)" 556 | // TODO - handle like volume? 557 | //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0])}, {it.unmute()}) 558 | sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0]) 559 | } 560 | 561 | def setPlayMode(mode) 562 | { 563 | log.trace "setPlayMode($mode)" 564 | coordinate({sonosAction("SetPlayMode", [InstanceID: 0, NewPlayMode: mode])}, {it.setPlayMode(mode)}) 565 | } 566 | 567 | def speak(text) { 568 | playTextAndResume(text) 569 | } 570 | 571 | def playTextAndResume(text, volume=null) 572 | { 573 | log.debug "playTextAndResume($text, $volume)" 574 | coordinate({ 575 | def sound = textToSpeech(text) 576 | playTrackAndResume(sound.uri, (sound.duration as Integer) + 1, volume) 577 | }, {it.playTextAndResume(text, volume)}) 578 | } 579 | 580 | def playTrackAndResume(uri, duration, volume=null) { 581 | log.debug "playTrackAndResume($uri, $duration, $volume)" 582 | coordinate({ 583 | def currentTrack = device.currentState("trackData")?.jsonValue 584 | def currentVolume = device.currentState("level")?.integerValue 585 | def currentStatus = device.currentValue("status") 586 | def level = volume as Integer 587 | 588 | def result = [] 589 | if (level) { 590 | log.trace "Stopping and setting level to $volume" 591 | result << sonosAction("Stop") 592 | result << setLocalLevel(level) 593 | } 594 | 595 | log.trace "Setting sound track: ${uri}" 596 | result << setTrack(uri) 597 | result << sonosAction("Play") 598 | 599 | if (currentTrack) { 600 | def delayTime = ((duration as Integer) * 1000)+3000 601 | if (level) { 602 | delayTime += 1000 603 | } 604 | result << delayAction(delayTime) 605 | log.trace "Delaying $delayTime ms before resumption" 606 | if (level) { 607 | log.trace "Restoring volume to $currentVolume" 608 | result << sonosAction("Stop") 609 | result << setLocalLevel(currentVolume) 610 | } 611 | log.trace "Restoring track $currentTrack.uri" 612 | result << setTrack(currentTrack) 613 | if (currentStatus == "playing") { 614 | result << sonosAction("Play") 615 | } 616 | } 617 | 618 | result = result.flatten() 619 | log.trace "Returning ${result.size()} commands" 620 | result 621 | }, {it.playTrackAndResume(uri, duration, volume)}) 622 | } 623 | 624 | def playTextAndRestore(text, volume=null) 625 | { 626 | log.debug "playTextAndResume($text, $volume)" 627 | coordinate({ 628 | def sound = textToSpeech(text) 629 | playTrackAndRestore(sound.uri, (sound.duration as Integer) + 1, volume) 630 | }, {it.playTextAndRestore(text, volume)}) 631 | } 632 | 633 | def playTrackAndRestore(uri, duration, volume=null) { 634 | log.debug "playTrackAndRestore($uri, $duration, $volume)" 635 | coordinate({ 636 | def currentTrack = device.currentState("trackData")?.jsonValue 637 | def currentVolume = device.currentState("level")?.integerValue 638 | def currentStatus = device.currentValue("status") 639 | def level = volume as Integer 640 | 641 | def result = [] 642 | if (level) { 643 | log.trace "Stopping and setting level to $volume" 644 | result << sonosAction("Stop") 645 | result << setLocalLevel(level) 646 | } 647 | 648 | log.trace "Setting sound track: ${uri}" 649 | result << setTrack(uri) 650 | result << sonosAction("Play") 651 | 652 | if (currentTrack) { 653 | def delayTime = ((duration as Integer) * 1000)+3000 654 | if (level) { 655 | delayTime += 1000 656 | } 657 | result << delayAction(delayTime) 658 | log.trace "Delaying $delayTime ms before restoration" 659 | if (level) { 660 | log.trace "Restoring volume to $currentVolume" 661 | result << sonosAction("Stop") 662 | result << setLocalLevel(currentVolume) 663 | } 664 | log.trace "Restoring track $currentTrack.uri" 665 | result << setTrack(currentTrack) 666 | } 667 | 668 | result = result.flatten() 669 | log.trace "Returning ${result.size()} commands" 670 | result 671 | }, {it.playTrackAndResume(uri, duration, volume)}) 672 | } 673 | 674 | def playTextAndTrack(text, trackData, volume=null) 675 | { 676 | log.debug "playTextAndTrack($text, $trackData, $volume)" 677 | coordinate({ 678 | def sound = textToSpeech(text) 679 | playSoundAndTrack(sound.uri, (sound.duration as Integer) + 1, trackData, volume) 680 | }, {it.playTextAndResume(text, volume)}) 681 | } 682 | 683 | def playSoundAndTrack(soundUri, duration, trackData, volume=null) { 684 | log.debug "playSoundAndTrack($soundUri, $duration, $trackUri, $volume)" 685 | coordinate({ 686 | def level = volume as Integer 687 | def result = [] 688 | if (level) { 689 | log.trace "Stopping and setting level to $volume" 690 | result << sonosAction("Stop") 691 | result << setLocalLevel(level) 692 | } 693 | 694 | log.trace "Setting sound track: ${soundUri}" 695 | result << setTrack(soundUri) 696 | result << sonosAction("Play") 697 | 698 | def delayTime = ((duration as Integer) * 1000)+3000 699 | result << delayAction(delayTime) 700 | log.trace "Delaying $delayTime ms before resumption" 701 | 702 | log.trace "Setting track $trackData" 703 | result << setTrack(trackData) 704 | result << sonosAction("Play") 705 | 706 | result = result.flatten() 707 | log.trace "Returning ${result.size()} commands" 708 | result 709 | }, {it.playTrackAndResume(uri, duration, volume)}) 710 | } 711 | 712 | def playTrackAtVolume(String uri, volume) { 713 | log.trace "playTrack()" 714 | coordinate({ 715 | def result = [] 716 | result << sonosAction("Stop") 717 | result << setLocalLevel(volume as Integer) 718 | result << setTrack(uri, metaData) 719 | result << sonosAction("Play") 720 | result.flatten() 721 | }, {it.playTrack(uri, metaData)}) 722 | } 723 | 724 | def playTrack(String uri, metaData="") { 725 | log.trace "playTrack()" 726 | coordinate({ 727 | def result = setTrack(uri, metaData) 728 | result << sonosAction("Play") 729 | result.flatten() 730 | }, {it.playTrack(uri, metaData)}) 731 | } 732 | 733 | def playTrack(Map trackData) { 734 | log.trace "playTrack(Map)" 735 | coordinate({ 736 | def result = setTrack(trackData) 737 | //result << delayAction(1000) 738 | result << sonosAction("Play") 739 | result.flatten() 740 | }, {it.playTrack(trackData)}) 741 | } 742 | 743 | def setTrack(Map trackData) { 744 | log.trace "setTrack($trackData.uri, ${trackData.metaData?.size()} B)" 745 | coordinate({ 746 | def data = trackData 747 | def result = [] 748 | if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) { 749 | // TODO - Clear queue? 750 | def uri = device.getDataValue('queueUri') 751 | result << sonosAction("RemoveAllTracksFromQueue", [InstanceID: 0]) 752 | //result << delayAction(500) 753 | result << sonosAction("AddURIToQueue", [InstanceID: 0, EnqueuedURI: data.uri, EnqueuedURIMetaData: data.metaData, DesiredFirstTrackNumberEnqueued: 0, EnqueueAsNext: 1]) 754 | //result << delayAction(500) 755 | result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData]) 756 | //result << delayAction(500) 757 | result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber]) 758 | } else { 759 | result = setTrack(data.uri, data.metaData) 760 | } 761 | result.flatten() 762 | }, {it.setTrack(trackData)}) 763 | } 764 | 765 | def setTrack(String uri, metaData="") 766 | { 767 | log.info "setTrack($uri, $trackNumber, ${metaData?.size()} B})" 768 | coordinate({ 769 | def result = [] 770 | result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData]) 771 | result 772 | }, {it.setTrack(uri, metaData)}) 773 | } 774 | 775 | def resumeTrack(Map trackData = null) { 776 | log.trace "resumeTrack()" 777 | coordinate({ 778 | def result = restoreTrack(trackData) 779 | //result << delayAction(500) 780 | result << sonosAction("Play") 781 | result 782 | }, {it.resumeTrack(trackData)}) 783 | } 784 | 785 | def restoreTrack(Map trackData = null) { 786 | log.trace "restoreTrack(${trackData?.uri})" 787 | coordinate({ 788 | def result = [] 789 | def data = trackData 790 | if (!data) { 791 | data = device.currentState("trackData")?.jsonValue 792 | } 793 | if (data) { 794 | if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) { 795 | def uri = device.getDataValue('queueUri') 796 | log.trace "Restoring queue position $data.trackNumber of $data.uri" 797 | result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: data.metaData]) 798 | //result << delayAction(500) 799 | result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber]) 800 | } else { 801 | log.trace "Setting track to $data.uri" 802 | //setTrack(data.uri, null, data.metaData) 803 | result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: data.uri, CurrentURIMetaData: data.metaData]) 804 | } 805 | } 806 | else { 807 | log.warn "Previous track data not found" 808 | } 809 | result 810 | }, {it.restoreTrack(trackData)}) 811 | } 812 | 813 | def playText(String msg) { 814 | coordinate({ 815 | def result = setText(msg) 816 | result << sonosAction("Play") 817 | }, {it.playText(msg)}) 818 | } 819 | 820 | def setText(String msg) { 821 | log.trace "setText($msg)" 822 | coordinate({ 823 | def sound = textToSpeech(msg) 824 | setTrack(sound.uri) 825 | }, {it.setText(msg)}) 826 | } 827 | 828 | // Custom commands 829 | 830 | def subscribe() { 831 | log.trace "subscribe()" 832 | def result = [] 833 | result << subscribeAction("/MediaRenderer/AVTransport/Event") 834 | result << delayAction(10000) 835 | result << subscribeAction("/MediaRenderer/RenderingControl/Event") 836 | result << delayAction(20000) 837 | result << subscribeAction("/ZoneGroupTopology/Event") 838 | 839 | result 840 | } 841 | 842 | def unsubscribe() { 843 | log.trace "unsubscribe()" 844 | def result = [ 845 | unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId')), 846 | unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId')), 847 | unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId')), 848 | 849 | unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId1')), 850 | unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId1')), 851 | unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId1')), 852 | 853 | unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId2')), 854 | unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId2')), 855 | unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId2')) 856 | ] 857 | updateDataValue("subscriptionId", "") 858 | updateDataValue("subscriptionId1", "") 859 | updateDataValue("subscriptionId2", "") 860 | result 861 | } 862 | 863 | def getVolume() 864 | { 865 | log.trace "getVolume()" 866 | sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"]) 867 | } 868 | 869 | def getCurrentMedia() 870 | { 871 | log.trace "getCurrentMedia()" 872 | sonosAction("GetPositionInfo", [InstanceID:0, Channel: "Master"]) 873 | } 874 | 875 | def getCurrentStatus() //transport info 876 | { 877 | log.trace "getCurrentStatus()" 878 | sonosAction("GetTransportInfo", [InstanceID:0]) 879 | } 880 | 881 | def getSystemString() 882 | { 883 | log.trace "getSystemString()" 884 | sonosAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName: "UMTracking"]) 885 | } 886 | 887 | private messageFilename(String msg) { 888 | msg.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/,'_') 889 | } 890 | 891 | private getCallBackAddress() 892 | { 893 | device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP") 894 | } 895 | 896 | private sonosAction(String action) { 897 | sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID:0, Speed:1]) 898 | } 899 | 900 | private sonosAction(String action, Map body) { 901 | sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", body) 902 | } 903 | 904 | private sonosAction(String action, String service, String path, Map body = [InstanceID:0, Speed:1]) { 905 | log.trace "sonosAction($action, $service, $path, $body)" 906 | def result = new physicalgraph.device.HubSoapAction( 907 | path: path ?: "/MediaRenderer/$service/Control", 908 | urn: "urn:schemas-upnp-org:service:$service:1", 909 | action: action, 910 | body: body, 911 | headers: [Host:getHostAddress(), CONNECTION: "close"] 912 | ) 913 | 914 | //log.trace "\n${result.action.encodeAsHTML()}" 915 | log.debug "sonosAction: $result.requestId" 916 | result 917 | } 918 | 919 | private subscribeAction(path, callbackPath="") { 920 | log.trace "subscribe($path, $callbackPath)" 921 | def address = getCallBackAddress() 922 | def ip = getHostAddress() 923 | 924 | def result = new physicalgraph.device.HubAction( 925 | method: "SUBSCRIBE", 926 | path: path, 927 | headers: [ 928 | HOST: ip, 929 | CALLBACK: "", 930 | NT: "upnp:event", 931 | TIMEOUT: "Second-28800"]) 932 | 933 | log.trace "SUBSCRIBE $path" 934 | //log.trace "\n${result.action.encodeAsHTML()}" 935 | result 936 | } 937 | 938 | private unsubscribeAction(path, sid) { 939 | log.trace "unsubscribe($path, $sid)" 940 | def ip = getHostAddress() 941 | def result = new physicalgraph.device.HubAction( 942 | method: "UNSUBSCRIBE", 943 | path: path, 944 | headers: [ 945 | HOST: ip, 946 | SID: "uuid:${sid}"]) 947 | 948 | log.trace "UNSUBSCRIBE $path" 949 | //log.trace "\n${result.action.encodeAsHTML()}" 950 | result 951 | } 952 | 953 | private delayAction(long time) { 954 | new physicalgraph.device.HubAction("delay $time") 955 | } 956 | 957 | private Integer convertHexToInt(hex) { 958 | Integer.parseInt(hex,16) 959 | } 960 | 961 | private String convertHexToIP(hex) { 962 | [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") 963 | } 964 | 965 | private getHostAddress() { 966 | def parts = device.deviceNetworkId.split(":") 967 | def ip = convertHexToIP(parts[0]) 968 | def port = convertHexToInt(parts[1]) 969 | return ip + ":" + port 970 | } 971 | 972 | private statusText(s) { 973 | switch(s) { 974 | case "PLAYING": 975 | return "playing" 976 | case "PAUSED_PLAYBACK": 977 | return "paused" 978 | case "STOPPED": 979 | return "stopped" 980 | default: 981 | return s 982 | } 983 | } 984 | 985 | private updateSid(sid) { 986 | if (sid) { 987 | def sid0 = device.getDataValue('subscriptionId') 988 | def sid1 = device.getDataValue('subscriptionId1') 989 | def sid2 = device.getDataValue('subscriptionId2') 990 | def sidNumber = device.getDataValue('sidNumber') ?: "0" 991 | 992 | log.trace "updateSid($sid), sid0=$sid0, sid1=$sid1, sid2=$sid2, sidNumber=$sidNumber" 993 | if (sidNumber == "0") { 994 | if (sid != sid1 && sid != sid2) { 995 | updateDataValue("subscriptionId", sid) 996 | updateDataValue("sidNumber", "1") 997 | } 998 | } 999 | else if (sidNumber == "1") { 1000 | if (sid != sid0 && sid != sid2) { 1001 | updateDataValue("subscriptionId1", sid) 1002 | updateDataValue("sidNumber", "2") 1003 | } 1004 | } 1005 | else { 1006 | if (sid != sid0 && sid != sid0) { 1007 | updateDataValue("subscriptionId2", sid) 1008 | updateDataValue("sidNumber", "0") 1009 | } 1010 | } 1011 | } 1012 | } 1013 | 1014 | private dniFromUri(uri) { 1015 | def segs = uri.replaceAll(/http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)\/.+/,'$1').split(":") 1016 | def nums = segs[0].split("\\.") 1017 | (nums.collect{hex(it.toInteger())}.join('') + ':' + hex(segs[-1].toInteger(),4)).toUpperCase() 1018 | } 1019 | 1020 | private hex(value, width=2) { 1021 | def s = new BigInteger(Math.round(value).toString()).toString(16) 1022 | while (s.size() < width) { 1023 | s = "0" + s 1024 | } 1025 | s 1026 | } 1027 | 1028 | private coordinate(closure1, closure2) { 1029 | def coordinator = device.getDataValue('coordinator') 1030 | if (coordinator) { 1031 | closure2(parent.getChildDevice(coordinator)) 1032 | } 1033 | else { 1034 | closure1() 1035 | } 1036 | } 1037 | -------------------------------------------------------------------------------- /StriimLight/StriimLight.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Striim Light v0.1 3 | * 4 | * Author: SmartThings - Ulises Mujica (Ule) - obycode 5 | * 6 | */ 7 | 8 | preferences { 9 | input(name: "customDelay", type: "enum", title: "Delay before msg (seconds)", options: ["0","1","2","3","4","5"]) 10 | input(name: "actionsDelay", type: "enum", title: "Delay between actions (seconds)", options: ["0","1","2","3"]) 11 | } 12 | metadata { 13 | // Automatically generated. Make future change here. 14 | definition (name: "Striim Light", namespace: "obycode", author: "SmartThings-Ulises Mujica") { 15 | capability "Actuator" 16 | capability "Switch" 17 | capability "Switch Level" 18 | capability "Color Control" 19 | capability "Refresh" 20 | capability "Sensor" 21 | capability "Polling" 22 | 23 | attribute "model", "string" 24 | attribute "temperature", "number" 25 | attribute "brightness", "number" 26 | attribute "lightMode", "string" 27 | 28 | command "setColorHex" "string" 29 | command "cycleColors" 30 | command "setTemperature" 31 | command "White" 32 | 33 | command "subscribe" 34 | command "unsubscribe" 35 | } 36 | 37 | // Main 38 | standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { 39 | state "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"off" 40 | state "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"on" 41 | } 42 | standardTile("reset", "device.reset", inactiveLabel: false, decoration: "flat") { 43 | state "default", label:"Reset Color", action:"reset", icon:"st.lights.philips.hue-single" 44 | } 45 | standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") { 46 | state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" 47 | } 48 | standardTile("cycleColors", "device.color", inactiveLabel: false, decoration: "flat") { 49 | state "default", label:"Colors", action:"cycleColors", icon:"st.unknown.thing.thing-circle" 50 | } 51 | standardTile("dimLevel", "device.level", inactiveLabel: false, decoration: "flat") { 52 | state "default", label:"Dimmer Level", icon:"st.switches.light.on" 53 | } 54 | 55 | controlTile("rgbSelector", "device.color", "color", height: 3, width: 3, inactiveLabel: false) { 56 | state "color", action:"color control.setColor" 57 | } 58 | controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, range:"(0..100)") { 59 | state "level", label:"Dimmer Level", action:"switch level.setLevel" 60 | } 61 | controlTile("tempSliderControl", "device.temperature", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") { 62 | state "temperature", label:"Temperature", action:"setTemperature" 63 | } 64 | valueTile("level", "device.level", inactiveLabel: false, decoration: "flat") { 65 | state "level", label: 'Level ${currentValue}%' 66 | } 67 | valueTile("lightMode", "device.lightMode", inactiveLabel: false, decoration: "flat") { 68 | state "lightMode", label: 'Mode: ${currentValue}', action:"White" 69 | } 70 | 71 | main(["switch"]) 72 | details(["switch", "levelSliderControl", "tempSliderControl", "rgbSelector", "refresh", "cycleColors", "level", "lightMode"]) 73 | } 74 | 75 | 76 | def parse(description) { 77 | def results = [] 78 | try { 79 | def msg = parseLanMessage(description) 80 | if (msg.headers) { 81 | def hdr = msg.header.split('\n')[0] 82 | if (hdr.size() > 36) { 83 | hdr = hdr[0..35] + "..." 84 | } 85 | 86 | def sid = "" 87 | if (msg.headers["SID"]) { 88 | sid = msg.headers["SID"] 89 | sid -= "uuid:" 90 | sid = sid.trim() 91 | 92 | } 93 | 94 | if (!msg.body) { 95 | if (sid) { 96 | updateSid(sid) 97 | } 98 | } 99 | else if (msg.xml) { 100 | // log.debug "received $msg" 101 | // log.debug "${msg.body}" 102 | 103 | // Process level status 104 | def node = msg.xml.Body.GetLoadLevelTargetResponse 105 | if (node.size()) { 106 | return sendEvent(name: "level", value: node, description: "$device.displayName Level is $node") 107 | } 108 | 109 | // Process subscription updates 110 | // On/Off 111 | node = msg.xml.property.Status 112 | if (node.size()) { 113 | def statusString = node == 0 ? "off" : "on" 114 | return sendEvent(name: "switch", value: statusString, description: "$device.displayName Switch is $statusString") 115 | } 116 | 117 | // Level 118 | node = msg.xml.property.LoadLevelStatus 119 | if (node.size()) { 120 | return sendEvent(name: "level", value: node, description: "$device.displayName Level is $node") 121 | } 122 | 123 | // TODO: Do something with Temperature, Brightness and Mode in the UI 124 | // Temperature 125 | node = msg.xml.property.Temperature 126 | if (node.size()) { 127 | def temp = node.text().toInteger() 128 | if (temp > 4000) { 129 | temp -= 4016 130 | } 131 | def result = [] 132 | result << sendEvent(name: "temperature", value: temp, description: "$device.displayName Temperature is $temp") 133 | result << sendEvent(name: "lightMode", value: "White", description: "$device.displayName Mode is White") 134 | return result 135 | } 136 | 137 | // brightness 138 | node = msg.xml.property.Brightness 139 | if (node.size()) { 140 | return sendEvent(name: "brightness", value: node, description: "$device.displayName Brightness is $node") 141 | } 142 | 143 | // Mode? 144 | // node = msg.xml.property.CurrentMode 145 | // if (node.size()) { 146 | // } 147 | 148 | // Color 149 | try { 150 | def bodyXml = parseXml(msg.xml.text()) 151 | node = bodyXml.RGB 152 | if (node.size()) { 153 | def fields = node.text().split(',') 154 | def colorHex = '#' + String.format('%02x%02x%02x', fields[0].toInteger(), fields[1].toInteger(), fields[2].toInteger()) 155 | def result = [] 156 | result << sendEvent(name: "color", value: colorHex, description:"$device.displayName Color is $colorHex") 157 | result << sendEvent(name: "lightMode", value: "Color", description: "$device.displayName Mode is Color") 158 | return result 159 | } 160 | } 161 | catch (org.xml.sax.SAXParseException e) { 162 | // log.debug "${msg.body}" 163 | } 164 | 165 | // Not sure what this is for? 166 | if (!results) { 167 | def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') 168 | .replaceAll('()','\n$1\n') 169 | .replaceAll('\n\n','\n').encodeAsHTML() : "" 170 | results << createEvent( 171 | name: "mediaRendererMessage", 172 | value: "${msg.body.encodeAsMD5()}", 173 | description: description, 174 | descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", 175 | data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", 176 | isStateChange: false, displayed: false) 177 | } 178 | } 179 | else { 180 | def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') 181 | .replaceAll('()','\n$1\n') 182 | .replaceAll('\n\n','\n').encodeAsHTML() : "" 183 | results << createEvent( 184 | name: "unknownMessage", 185 | value: "${msg.body.encodeAsMD5()}", 186 | description: description, 187 | descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", 188 | data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", 189 | isStateChange: true, displayed: true) 190 | } 191 | } 192 | } 193 | catch (Throwable t) { 194 | //results << createEvent(name: "parseError", value: "$t") 195 | sendEvent(name: "parseError", value: "$t", description: description) 196 | throw t 197 | } 198 | results 199 | } 200 | 201 | def installed() { 202 | sendEvent(name:"model",value:getDataValue("model"),isStateChange:true) 203 | def result = [delayAction(5000)] 204 | result << refresh() 205 | result.flatten() 206 | } 207 | 208 | def on(){ 209 | dimmableLightAction("SetTarget", "SwitchPower", getDataValue("spcurl"), [newTargetValue: 1]) 210 | } 211 | 212 | def off(){ 213 | dimmableLightAction("SetTarget", "SwitchPower", getDataValue("spcurl"), [newTargetValue: 0]) 214 | } 215 | 216 | def poll() { 217 | refresh() 218 | } 219 | 220 | def refresh() { 221 | def eventTime = new Date().time 222 | 223 | if(eventTime > state.secureEventTime ?:0) { 224 | if ((state.lastRefreshTime ?: 0) > (state.lastStatusTime ?:0)) { 225 | sendEvent(name: "status", value: "no_device_present", data: "no_device_present", displayed: false) 226 | } 227 | state.lastRefreshTime = eventTime 228 | log.trace "Refresh()" 229 | def result = [] 230 | result << subscribe() 231 | result << getCurrentLevel() 232 | result.flatten() 233 | } 234 | else { 235 | log.trace "Refresh skipped" 236 | } 237 | } 238 | 239 | def setLevel(val) { 240 | dimmableLightAction("SetLoadLevelTarget", "Dimming", getDataValue("dcurl"), [newLoadlevelTarget: val]) 241 | } 242 | 243 | def setTemperature(val) { 244 | // The API only accepts values 2700 - 6000, but it actually wants values 245 | // between 0-100, so we need to do this weird offsetting (trial and error) 246 | def offsetVal = val.toInteger() + 4016 247 | awoxAction("SetTemperature", "X_WhiteLight", getDataValue("xwlcurl"), [Temperature: offsetVal.toString()]) 248 | } 249 | 250 | def White() { 251 | def lastTemp = device.currentValue("temperature") + 4016 252 | awoxAction("SetTemperature", "X_WhiteLight", getDataValue("xwlcurl"), [Temperature: lastTemp]) 253 | } 254 | 255 | def setColor(value) { 256 | def colorString = value.red.toString() + "," + value.green.toString() + "," + value.blue.toString() 257 | awoxAction("SetRGBColor", "X_ColorLight", getDataValue("xclcurl"), [RGBColor: colorString]) 258 | } 259 | 260 | def setColorHex(hexString) { 261 | def colorString = convertHexToInt(hexString[1..2]).toString() + "," + 262 | convertHexToInt(hexString[3..4]).toString() + "," + 263 | convertHexToInt(hexString[5..6]).toString() 264 | awoxAction("SetRGBColor", "X_ColorLight", getDataValue("xclcurl"), [RGBColor: colorString]) 265 | } 266 | 267 | // Custom commands 268 | 269 | // This method models the button on the StriimLight remote that cycles through 270 | // some pre-defined colors 271 | def cycleColors() { 272 | def currentColor = device.currentValue("color") 273 | switch(currentColor) { 274 | case "#0000ff": 275 | setColorHex("#ffff00") 276 | break 277 | case "#ffff00": 278 | setColorHex("#00ffff") 279 | break 280 | case "#00ffff": 281 | setColorHex("#ff00ff") 282 | break 283 | case "#ff00ff": 284 | setColorHex("#ff0000") 285 | break 286 | case "#ff0000": 287 | setColorHex("#00ff00") 288 | break 289 | case "#00ff00": 290 | setColorHex("#0000ff") 291 | break 292 | default: 293 | setColorHex("#0000ff") 294 | break 295 | } 296 | } 297 | 298 | def subscribe() { 299 | log.trace "subscribe()" 300 | def result = [] 301 | result << subscribeAction(getDataValue("deurl")) 302 | result << delayAction(2500) 303 | result << subscribeAction(getDataValue("speurl")) 304 | result << delayAction(2500) 305 | result << subscribeAction(getDataValue("xcleurl")) 306 | result << delayAction(2500) 307 | result << subscribeAction(getDataValue("xwleurl")) 308 | result 309 | } 310 | 311 | def unsubscribe() { 312 | def result = [ 313 | unsubscribeAction(getDataValue("deurl"), device.getDataValue('subscriptionId')), 314 | unsubscribeAction(getDataValue("speurl"), device.getDataValue('subscriptionId')), 315 | unsubscribeAction(getDataValue("xcleurl"), device.getDataValue('subscriptionId')), 316 | unsubscribeAction(getDataValue("xwleurl"), device.getDataValue('subscriptionId')), 317 | 318 | 319 | unsubscribeAction(getDataValue("deurl"), device.getDataValue('subscriptionId1')), 320 | unsubscribeAction(getDataValue("speurl"), device.getDataValue('subscriptionId1')), 321 | unsubscribeAction(getDataValue("xcleurl"), device.getDataValue('subscriptionId1')), 322 | unsubscribeAction(getDataValue("xwleurl"), device.getDataValue('subscriptionId1')), 323 | 324 | ] 325 | updateDataValue("subscriptionId", "") 326 | updateDataValue("subscriptionId1", "") 327 | result 328 | } 329 | 330 | def getCurrentLevel() 331 | { 332 | dimmableLightAction("GetLoadLevelTarget", "Dimming", getDataValue("dcurl")) 333 | } 334 | 335 | def getSystemString() 336 | { 337 | mediaRendererAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName: "UMTracking"]) 338 | } 339 | 340 | private getCallBackAddress() 341 | { 342 | device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP") 343 | } 344 | 345 | private dimmableLightAction(String action, String service, String path, Map body = [:]) { 346 | // log.debug "path is $path, service is $service, action is $action, body is $body" 347 | def result = new physicalgraph.device.HubSoapAction( 348 | path: path ?: "/DimmableLight/$service/Control", 349 | urn: "urn:schemas-upnp-org:service:$service:1", 350 | action: action, 351 | body: body, 352 | headers: [Host:getHostAddress(), CONNECTION: "close"]) 353 | result 354 | } 355 | 356 | private awoxAction(String action, String service, String path, Map body = [:]) { 357 | // log.debug "path is $path, service is $service, action is $action, body is $body" 358 | def result = new physicalgraph.device.HubSoapAction( 359 | path: path ?: "/DimmableLight/$service/Control", 360 | urn: "urn:schemas-awox-com:service:$service:1", 361 | action: action, 362 | body: body, 363 | headers: [Host:getHostAddress(), CONNECTION: "close"]) 364 | result 365 | } 366 | 367 | private subscribeAction(path, callbackPath="") { 368 | def address = getCallBackAddress() 369 | def ip = getHostAddress() 370 | def result = new physicalgraph.device.HubAction( 371 | method: "SUBSCRIBE", 372 | path: path, 373 | headers: [ 374 | HOST: ip, 375 | CALLBACK: "", 376 | NT: "upnp:event", 377 | TIMEOUT: "Second-600"]) 378 | result 379 | } 380 | 381 | private unsubscribeAction(path, sid) { 382 | def ip = getHostAddress() 383 | def result = new physicalgraph.device.HubAction( 384 | method: "UNSUBSCRIBE", 385 | path: path, 386 | headers: [ 387 | HOST: ip, 388 | SID: "uuid:${sid}"]) 389 | result 390 | } 391 | 392 | private delayAction(long time) { 393 | new physicalgraph.device.HubAction("delay $time") 394 | } 395 | 396 | private Integer convertHexToInt(hex) { 397 | Integer.parseInt(hex,16) 398 | } 399 | 400 | private String convertHexToIP(hex) { 401 | [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") 402 | } 403 | 404 | private getHostAddress() { 405 | def parts = getDataValue("dni")?.split(":") 406 | def ip = convertHexToIP(parts[0]) 407 | def port = convertHexToInt(parts[1]) 408 | return ip + ":" + port 409 | } 410 | 411 | private updateSid(sid) { 412 | if (sid) { 413 | def sid0 = device.getDataValue('subscriptionId') 414 | def sid1 = device.getDataValue('subscriptionId1') 415 | def sidNumber = device.getDataValue('sidNumber') ?: "0" 416 | if (sidNumber == "0") { 417 | if (sid != sid1) { 418 | updateDataValue("subscriptionId", sid) 419 | updateDataValue("sidNumber", "1") 420 | } 421 | } 422 | else { 423 | if (sid != sid0) { 424 | updateDataValue("subscriptionId1", sid) 425 | updateDataValue("sidNumber", "0") 426 | } 427 | } 428 | } 429 | } 430 | 431 | private dniFromUri(uri) { 432 | def segs = uri.replaceAll(/http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)\/.+/,'$1').split(":") 433 | def nums = segs[0].split("\\.") 434 | (nums.collect{hex(it.toInteger())}.join('') + ':' + hex(segs[-1].toInteger(),4)).toUpperCase() 435 | } 436 | 437 | private hex(value, width=2) { 438 | def s = new BigInteger(Math.round(value).toString()).toString(16) 439 | while (s.size() < width) { 440 | s = "0" + s 441 | } 442 | s 443 | } 444 | -------------------------------------------------------------------------------- /StriimLight/StriimLightConnect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * StriimLight Connect v 0.1 3 | * 4 | * Author: SmartThings - Ulises Mujica - obycode 5 | */ 6 | 7 | definition( 8 | name: "StriimLight (Connect)", 9 | namespace: "obycode", 10 | author: "SmartThings - Ulises Mujica - obycode", 11 | description: "Allows you to control your StriimLight from the SmartThings app. Control the music and the light.", 12 | category: "SmartThings Labs", 13 | iconUrl: "http://obycode.com/img/icons/AwoxGreen.png", 14 | iconX2Url: "http://obycode.com/img/icons/AwoxGreen@2x.png" 15 | ) 16 | 17 | preferences { 18 | page(name: "MainPage", title: "Find and config your StriimLights",nextPage:"", install:true, uninstall: true){ 19 | section("") { 20 | href(name: "discover",title: "Discovery process",required: false,page: "striimLightDiscovery", description: "tap to start searching") 21 | } 22 | section("Options", hideable: true, hidden: true) { 23 | input("refreshSLInterval", "number", title:"Enter refresh interval (min)", defaultValue:"5", required:false) 24 | } 25 | } 26 | page(name: "striimLightDiscovery", title:"Discovery Started!", nextPage:"") 27 | } 28 | 29 | def striimLightDiscovery() 30 | { 31 | if(canInstallLabs()) 32 | { 33 | int striimLightRefreshCount = !state.striimLightRefreshCount ? 0 : state.striimLightRefreshCount as int 34 | state.striimLightRefreshCount = striimLightRefreshCount + 1 35 | def refreshInterval = 5 36 | 37 | def options = striimLightsDiscovered() ?: [] 38 | 39 | def numFound = options.size() ?: 0 40 | 41 | if(!state.subscribe) { 42 | subscribe(location, null, locationHandler, [filterEvents:false]) 43 | state.subscribe = true 44 | } 45 | 46 | //striimLight discovery request every 5 //25 seconds 47 | if((striimLightRefreshCount % 8) == 0) { 48 | discoverstriimLights() 49 | } 50 | 51 | //setup.xml request every 3 seconds except on discoveries 52 | if(((striimLightRefreshCount % 1) == 0) && ((striimLightRefreshCount % 8) != 0)) { 53 | verifystriimLightPlayer() 54 | } 55 | 56 | return dynamicPage(name:"striimLightDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval) { 57 | section("Please wait while we discover your Striim Light. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { 58 | input "selectedstriimLight", "enum", required:false, title:"Select Striim Light(s) (${numFound} found)", multiple:true, options:options 59 | } 60 | } 61 | } 62 | else 63 | { 64 | def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. 65 | 66 | To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" 67 | 68 | return dynamicPage(name:"striimLightDiscovery", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { 69 | section("Upgrade") { 70 | paragraph "$upgradeNeeded" 71 | } 72 | } 73 | } 74 | } 75 | 76 | private discoverstriimLights() 77 | { 78 | sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:DimmableLight:1", physicalgraph.device.Protocol.LAN)) 79 | } 80 | 81 | 82 | private verifystriimLightPlayer() { 83 | def devices = getstriimLightPlayer().findAll { it?.value?.verified != true } 84 | 85 | devices.each { 86 | verifystriimLight((it?.value?.ip + ":" + it?.value?.port), it?.value?.ssdpPath) 87 | } 88 | } 89 | 90 | private verifystriimLight(String deviceNetworkId, String ssdpPath) { 91 | String ip = getHostAddress(deviceNetworkId) 92 | if(!ssdpPath){ 93 | ssdpPath = "/" 94 | } 95 | 96 | sendHubCommand(new physicalgraph.device.HubAction("""GET $ssdpPath HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) 97 | //sendHubCommand(new physicalgraph.device.HubAction("""GET /aw/DimmableLight_SwitchPower/scpd.xml HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) 98 | } 99 | 100 | Map striimLightsDiscovered() { 101 | def vstriimLights = getVerifiedstriimLightPlayer() 102 | def map = [:] 103 | vstriimLights.each { 104 | def value = "${it.value.name}" 105 | def key = it.value.ip + ":" + it.value.port 106 | map["${key}"] = value 107 | } 108 | map 109 | } 110 | 111 | def getstriimLightPlayer() 112 | { 113 | state.striimLights = state.striimLights ?: [:] 114 | } 115 | 116 | def getVerifiedstriimLightPlayer() 117 | { 118 | getstriimLightPlayer().findAll{ it?.value?.verified == true } 119 | } 120 | 121 | def installed() { 122 | initialize()} 123 | 124 | def updated() { 125 | unschedule() 126 | initialize() 127 | } 128 | 129 | def uninstalled() { 130 | def devices = getChildDevices() 131 | devices.each { 132 | deleteChildDevice(it.deviceNetworkId) 133 | } 134 | } 135 | 136 | def initialize() { 137 | // remove location subscription aftwards 138 | unsubscribe() 139 | state.subscribe = false 140 | 141 | unschedule() 142 | 143 | if (selectedstriimLight) { 144 | addstriimLight() 145 | } 146 | scheduleActions() 147 | scheduledRefreshHandler() 148 | } 149 | 150 | def scheduledRefreshHandler() { 151 | refreshAll() 152 | } 153 | 154 | def scheduledActionsHandler() { 155 | syncDevices() 156 | runIn(60, scheduledRefreshHandler) 157 | 158 | } 159 | 160 | private scheduleActions() { 161 | def minutes = Math.max(settings.refreshSLInterval.toInteger(),3) 162 | def cron = "0 0/${minutes} * * * ?" 163 | schedule(cron, scheduledActionsHandler) 164 | } 165 | 166 | 167 | 168 | private syncDevices() { 169 | log.debug "syncDevices()" 170 | if(!state.subscribe) { 171 | subscribe(location, null, locationHandler, [filterEvents:false]) 172 | state.subscribe = true 173 | } 174 | 175 | discoverstriimLights() 176 | } 177 | 178 | private refreshAll(){ 179 | log.trace "refresh all" 180 | childDevices*.refresh() 181 | } 182 | 183 | def addstriimLight() { 184 | def players = getVerifiedstriimLightPlayer() 185 | def runSubscribe = false 186 | selectedstriimLight.each { dni -> 187 | def d = getChildDevice(dni) 188 | log.trace "dni $dni" 189 | if(!d) { 190 | def newLight = players.find { (it.value.ip + ":" + it.value.port) == dni } 191 | if (newLight){ 192 | //striimLight 193 | d = addChildDevice("obycode", "Striim Light", dni, newLight?.value.hub, [label:"${newLight?.value.name} Striim Light","data":["model":newLight?.value.model,"dcurl":newLight?.value.dcurl,"deurl":newLight?.value.deurl,"spcurl":newLight?.value.spcurl,"speurl":newLight?.value.speurl,"xclcurl":newLight?.value.xclcurl,"xcleurl":newLight?.value.xcleurl,"xwlcurl":newLight?.value.xwlcurl,"xwleurl":newLight?.value.xwleurl,"udn":newLight?.value.udn,"dni":dni]]) 194 | } 195 | runSubscribe = true 196 | } 197 | } 198 | } 199 | 200 | def locationHandler(evt) { 201 | def description = evt.description 202 | def hub = evt?.hubId 203 | def parsedEvent = parseEventMessage(description) 204 | def msg = parseLanMessage(description) 205 | parsedEvent << ["hub":hub] 206 | 207 | if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:DimmableLight:1")) 208 | { //SSDP DISCOVERY EVENTS 209 | log.debug "Striim Light device found" + parsedEvent 210 | def striimLights = getstriimLightPlayer() 211 | 212 | 213 | if (!(striimLights."${parsedEvent.ssdpUSN.toString()}")) 214 | { //striimLight does not exist 215 | striimLights << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] 216 | } 217 | else 218 | { // update the values 219 | 220 | def d = striimLights."${parsedEvent.ssdpUSN.toString()}" 221 | boolean deviceChangedValues = false 222 | if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { 223 | d.ip = parsedEvent.ip 224 | d.port = parsedEvent.port 225 | deviceChangedValues = true 226 | } 227 | if (deviceChangedValues) { 228 | def children = getChildDevices() 229 | children.each { 230 | if (parsedEvent.ssdpUSN.toString().contains(it.getDataValue("udn"))) { 231 | it.setDeviceNetworkId((parsedEvent.ip + ":" + parsedEvent.port)) //could error if device with same dni already exists 232 | it.updateDataValue("dni", (parsedEvent.ip + ":" + parsedEvent.port)) 233 | log.trace "Updated Device IP" 234 | } 235 | } 236 | } 237 | } 238 | } 239 | if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:MediaRenderer:1")) 240 | { //SSDP DISCOVERY EVENTS 241 | log.debug "in media renderer section!!!!" 242 | } 243 | else if (parsedEvent.headers && parsedEvent.body) 244 | { // MEDIARENDER RESPONSES 245 | def headerString = new String(parsedEvent.headers.decodeBase64()) 246 | def bodyString = new String(parsedEvent.body.decodeBase64()) 247 | 248 | def type = (headerString =~ /Content-Type:.*/) ? (headerString =~ /Content-Type:.*/)[0] : null 249 | def body 250 | if (bodyString?.contains("xml")) 251 | { // description.xml response (application/xml) 252 | body = new XmlSlurper().parseText(bodyString) 253 | log.debug "got $body" 254 | 255 | // Find Awox devices 256 | if ( body?.device?.manufacturer?.text().startsWith("Awox") && body?.device?.deviceType?.text().contains("urn:schemas-upnp-org:device:DimmableLight:1")) 257 | { 258 | def dcurl = "" 259 | def deurl = "" 260 | def spcurl = "" 261 | def speurl = "" 262 | def xclcurl = "" 263 | def xcleurl = "" 264 | def xwlcurl = "" 265 | def xwleurl = "" 266 | 267 | body?.device?.serviceList?.service?.each { 268 | if (it?.serviceType?.text().contains("Dimming")) { 269 | dcurl = it?.controlURL.text() 270 | deurl = it?.eventSubURL.text() 271 | } 272 | else if (it?.serviceType?.text().contains("SwitchPower")) { 273 | spcurl = it?.controlURL.text() 274 | speurl = it?.eventSubURL.text() 275 | } 276 | else if (it?.serviceType?.text().contains("X_ColorLight")) { 277 | xclcurl = it?.controlURL.text() 278 | xcleurl = it?.eventSubURL.text() 279 | } 280 | else if (it?.serviceType?.text().contains("X_WhiteLight")) { 281 | xwlcurl = it?.controlURL.text() 282 | xwleurl = it?.eventSubURL.text() 283 | } 284 | } 285 | 286 | 287 | def striimLights = getstriimLightPlayer() 288 | def player = striimLights.find {it?.key?.contains(body?.device?.UDN?.text())} 289 | if (player) 290 | { 291 | player.value << [name:body?.device?.friendlyName?.text(),model:body?.device?.modelName?.text(), serialNumber:body?.device?.UDN?.text(), verified: true,dcurl:dcurl,deurl:deurl,spcurl:spcurl,speurl:speurl,xclcurl:xclcurl,xcleurl:xcleurl,xwlcurl:xwlcurl,xwleurl:xwleurl,udn:body?.device?.UDN?.text()] 292 | } 293 | 294 | } 295 | } 296 | else if(type?.contains("json")) 297 | { //(application/json) 298 | body = new groovy.json.JsonSlurper().parseText(bodyString) 299 | } 300 | } 301 | } 302 | 303 | private def parseEventMessage(Map event) { 304 | //handles striimLight attribute events 305 | return event 306 | } 307 | 308 | private def parseEventMessage(String description) { 309 | def event = [:] 310 | def parts = description.split(',') 311 | parts.each { part -> 312 | part = part.trim() 313 | if (part.startsWith('devicetype:')) { 314 | def valueString = part.split(":")[1].trim() 315 | event.devicetype = valueString 316 | } 317 | else if (part.startsWith('mac:')) { 318 | def valueString = part.split(":")[1].trim() 319 | if (valueString) { 320 | event.mac = valueString 321 | } 322 | } 323 | else if (part.startsWith('networkAddress:')) { 324 | def valueString = part.split(":")[1].trim() 325 | if (valueString) { 326 | event.ip = valueString 327 | } 328 | } 329 | else if (part.startsWith('deviceAddress:')) { 330 | def valueString = part.split(":")[1].trim() 331 | if (valueString) { 332 | event.port = valueString 333 | } 334 | } 335 | else if (part.startsWith('ssdpPath:')) { 336 | def valueString = part.split(":")[1].trim() 337 | if (valueString) { 338 | event.ssdpPath = valueString 339 | } 340 | } 341 | else if (part.startsWith('ssdpUSN:')) { 342 | part -= "ssdpUSN:" 343 | def valueString = part.trim() 344 | if (valueString) { 345 | event.ssdpUSN = valueString 346 | } 347 | } 348 | else if (part.startsWith('ssdpTerm:')) { 349 | part -= "ssdpTerm:" 350 | def valueString = part.trim() 351 | if (valueString) { 352 | event.ssdpTerm = valueString 353 | } 354 | } 355 | else if (part.startsWith('headers')) { 356 | part -= "headers:" 357 | def valueString = part.trim() 358 | if (valueString) { 359 | event.headers = valueString 360 | } 361 | } 362 | else if (part.startsWith('body')) { 363 | part -= "body:" 364 | def valueString = part.trim() 365 | if (valueString) { 366 | event.body = valueString 367 | } 368 | } 369 | } 370 | 371 | event 372 | } 373 | 374 | 375 | /////////CHILD DEVICE METHODS 376 | def parse(childDevice, description) { 377 | def parsedEvent = parseEventMessage(description) 378 | 379 | if (parsedEvent.headers && parsedEvent.body) { 380 | def headerString = new String(parsedEvent.headers.decodeBase64()) 381 | def bodyString = new String(parsedEvent.body.decodeBase64()) 382 | 383 | def body = new groovy.json.JsonSlurper().parseText(bodyString) 384 | } else { 385 | return [] 386 | } 387 | } 388 | 389 | private Integer convertHexToInt(hex) { 390 | Integer.parseInt(hex,16) 391 | } 392 | 393 | private String convertHexToIP(hex) { 394 | [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") 395 | } 396 | 397 | private getHostAddress(d) { 398 | def parts = d.split(":") 399 | def ip = convertHexToIP(parts[0]) 400 | def port = convertHexToInt(parts[1]) 401 | return ip + ":" + port 402 | } 403 | 404 | private Boolean canInstallLabs() 405 | { 406 | return hasAllHubsOver("000.011.00603") 407 | } 408 | 409 | private Boolean hasAllHubsOver(String desiredFirmware) 410 | { 411 | return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } 412 | } 413 | 414 | private List getRealHubFirmwareVersions() 415 | { 416 | return location.hubs*.firmwareVersionString.findAll { it } 417 | } 418 | -------------------------------------------------------------------------------- /VirtualButtons/AeonMinimote.groovy: -------------------------------------------------------------------------------- 1 | metadata { 2 | definition (name: "Aeon Minimote", namespace: "smartthings", author: "SmartThings") { 3 | capability "Actuator" 4 | capability "Button" 5 | capability "Configuration" 6 | capability "Sensor" 7 | attribute "numButtons", "STRING" 8 | 9 | fingerprint deviceId: "0x0101", inClusters: "0x86,0x72,0x70,0x9B", outClusters: "0x26,0x2B" 10 | fingerprint deviceId: "0x0101", inClusters: "0x86,0x72,0x70,0x9B,0x85,0x84", outClusters: "0x26" // old style with numbered buttons 11 | } 12 | 13 | simulator { 14 | status "button 1 pushed": "command: 2001, payload: 01" 15 | status "button 1 held": "command: 2001, payload: 15" 16 | status "button 2 pushed": "command: 2001, payload: 29" 17 | status "button 2 held": "command: 2001, payload: 3D" 18 | status "button 3 pushed": "command: 2001, payload: 51" 19 | status "button 3 held": "command: 2001, payload: 65" 20 | status "button 4 pushed": "command: 2001, payload: 79" 21 | status "button 4 held": "command: 2001, payload: 8D" 22 | status "wakeup": "command: 8407, payload: " 23 | } 24 | tiles { 25 | standardTile("button", "device.button", width: 2, height: 2) { 26 | state "default", label: "", icon: "st.unknown.zwave.remote-controller", backgroundColor: "#ffffff" 27 | } 28 | // Configure button. Syncronize the device capabilities that the UI provides 29 | standardTile("configure", "device.configure", inactiveLabel: false, decoration: "flat") { 30 | state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure" 31 | } 32 | main "button" 33 | details(["button", "configure"]) 34 | } 35 | } 36 | 37 | def parse(String description) { 38 | def results = [] 39 | if (description.startsWith("Err")) { 40 | results = createEvent(descriptionText:description, displayed:true) 41 | } else { 42 | def cmd = zwave.parse(description, [0x2B: 1, 0x80: 1, 0x84: 1]) 43 | if(cmd) results += zwaveEvent(cmd) 44 | if(!results) results = [ descriptionText: cmd, displayed: false ] 45 | } 46 | // log.debug("Parsed '$description' to $results") 47 | return results 48 | } 49 | 50 | def zwaveEvent(physicalgraph.zwave.commands.wakeupv1.WakeUpNotification cmd) { 51 | def results = [createEvent(descriptionText: "$device.displayName woke up", isStateChange: false)] 52 | 53 | results += configurationCmds().collect{ response(it) } 54 | results << response(zwave.wakeUpV1.wakeUpNoMoreInformation().format()) 55 | 56 | return results 57 | } 58 | 59 | def buttonEvent(button, held) { 60 | button = button as Integer 61 | if (held) { 62 | createEvent(name: "button", value: "held", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was held", isStateChange: true) 63 | } else { 64 | createEvent(name: "button", value: "pushed", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was pushed", isStateChange: true) 65 | } 66 | } 67 | 68 | def zwaveEvent(physicalgraph.zwave.commands.sceneactivationv1.SceneActivationSet cmd) { 69 | Integer button = ((cmd.sceneId + 1) / 2) as Integer 70 | Boolean held = !(cmd.sceneId % 2) 71 | buttonEvent(button, held) 72 | } 73 | 74 | def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicSet cmd) { 75 | Integer button = (cmd.value / 40 + 1) as Integer 76 | Boolean held = (button * 40 - cmd.value) <= 20 77 | buttonEvent(button, held) 78 | } 79 | 80 | def zwaveEvent(physicalgraph.zwave.Command cmd) { 81 | [ descriptionText: "$device.displayName: $cmd", linkText:device.displayName, displayed: false ] 82 | } 83 | 84 | def configurationCmds() { 85 | def cmds = [] 86 | def hubId = zwaveHubNodeId 87 | (1..4).each { button -> 88 | cmds << zwave.configurationV1.configurationSet(parameterNumber: 240+button, scaledConfigurationValue: 1).format() 89 | } 90 | (1..4).each { button -> 91 | cmds << zwave.configurationV1.configurationSet(parameterNumber: (button-1)*40, configurationValue: [hubId, (button-1)*40 + 1, 0, 0]).format() 92 | cmds << zwave.configurationV1.configurationSet(parameterNumber: (button-1)*40 + 20, configurationValue: [hubId, (button-1)*40 + 21, 0, 0]).format() 93 | } 94 | cmds 95 | } 96 | 97 | def configure() { 98 | // Set the number of buttons to 4 99 | sendEvent(name: "numButtons", value: "4", displayed: false) 100 | 101 | def cmds = configurationCmds() 102 | log.debug("Sending configuration: $cmds") 103 | return cmds 104 | } 105 | -------------------------------------------------------------------------------- /VirtualButtons/README.md: -------------------------------------------------------------------------------- 1 | Multi-Button Scene Controller 2 | ============================================ 3 | 4 | These files are used to connect a multi-button scene controller to SmartThings, and then create a virtual button for each of the physical buttons on the device. This way, each button can be used with a SmartApp supporting the capability.button. This could change in the future if the capability changes to support multiple button devices. 5 | 6 | It works with the Enerwave 7 Button Scene Controller (ZWN-SC7) using the included device type (ZWN-SC7.groovy), based on the one developed by Matt Frank, and should also work with the official device type for the Aeon Minimote. 7 | 8 | Installation 9 | ------------ 10 | To use with the ZWN-SC7, first publish the device type, then use the standard SmartThings add device procedures. It should correctly identify the device by its fingerprint. 11 | 12 | Next, publish the device type, VirtualButton.groovy, and the SmartApp, VirtualButtons.groovy to your account via the IDE. In the SmartThings app, go to SmartSetup -> My Apps and find "Virtual Buttons". Select the multi-button device and press Done. You will now see N new buttons (4 for the Minimote, 7 for the ZWN-SC7) in your Things section. You may now use these for your SmartApps, or add these new buttons to SmartRules (http://smartrulesapp.com) and easily create rules for them. 13 | -------------------------------------------------------------------------------- /VirtualButtons/Securify-KeyFob.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Securify Key Fob 3 | * 4 | * Author: Kevin Tierney based on code from Gilbert Chan; numButtons added by obycode 5 | * Date Created: 2014-12-18 6 | * Last Updated: 2015-05-13 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 9 | * in compliance with the License. You may obtain a copy of the License at: 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 14 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 15 | * for the specific language governing permissions and limitations under the License. 16 | * 17 | */ 18 | 19 | metadata { 20 | definition (name: "Securifi Key Fob", namespace: "tierneykev", author: "Kevin Tierney") { 21 | 22 | capability "Configuration" 23 | capability "Refresh" 24 | capability "Button" 25 | 26 | attribute "button2","ENUM",["released","pressed"] 27 | attribute "button3","ENUM",["released","pressed"] 28 | attribute "numButtons", "STRING" 29 | 30 | fingerprint profileId: "0104", deviceId: "0401", inClusters: "0000,0003,0500", outClusters: "0003,0501" 31 | } 32 | 33 | tiles { 34 | 35 | standardTile("button1", "device.button", width: 1, height: 1) { 36 | state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") 37 | state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") 38 | } 39 | 40 | standardTile("button2", "device.button2", width: 1, height: 1) { 41 | state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") 42 | state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") 43 | } 44 | 45 | standardTile("button3", "device.button3", width: 1, height: 1) { 46 | state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") 47 | state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") 48 | } 49 | 50 | main (["button3", "button2", "button1"]) 51 | details (["button3", "button2", "button1"]) 52 | } 53 | } 54 | 55 | 56 | 57 | 58 | def parse(String description) { 59 | 60 | if (description?.startsWith('enroll request')) { 61 | 62 | List cmds = enrollResponse() 63 | log.debug "enroll response: ${cmds}" 64 | def result = cmds?.collect { new physicalgraph.device.HubAction(it) } 65 | return result 66 | } 67 | else if (description?.startsWith('catchall:')) { 68 | def msg = zigbee.parse(description) 69 | log.debug msg 70 | buttonPush(msg.data[0]) 71 | } 72 | else { 73 | log.debug "parse description: $description" 74 | } 75 | 76 | } 77 | 78 | def buttonPush(button){ 79 | def name = null 80 | if (button == 0) { 81 | name = "1" 82 | def currentST = device.currentState("button")?.value 83 | log.debug "Unlock button Pushed" 84 | } 85 | else if (button == 2) { 86 | name = "2" 87 | def currentST = device.currentState("button2")?.value 88 | log.debug "Home button pushed" 89 | } 90 | else if (button == 3) { 91 | name = "3" 92 | def currentST = device.currentState("button3")?.value 93 | log.debug "Lock Button pushed" 94 | } 95 | 96 | def result = createEvent(name: "button", value: "pushed", data: [buttonNumber: name], descriptionText: "$device.displayName button $name was pushed", isStateChange: true) 97 | log.debug "Parse returned ${result?.descriptionText}" 98 | return result 99 | } 100 | 101 | 102 | def enrollResponse() { 103 | log.debug "Sending enroll response" 104 | [ 105 | "raw 0x500 {01 23 00 00 00}", "delay 200", 106 | "send 0x${device.deviceNetworkId} 0x08 1" 107 | ] 108 | } 109 | 110 | 111 | 112 | def configure(){ 113 | log.debug "Config Called" 114 | 115 | // Set the number of buttons to 3 116 | sendEvent(name: "numButtons", value: "3", displayed: false) 117 | 118 | def configCmds = [ 119 | "zcl global write 0x500 0x10 0xf0 {${device.zigbeeId}}", "delay 200", 120 | "send 0x${device.deviceNetworkId} 0x08 1", "delay 1500", 121 | "zdo bind 0x${device.deviceNetworkId} 0x08 0x01 0x0501 {${device.zigbeeId}} {}", "delay 500", 122 | "zdo bind 0x${device.deviceNetworkId} 0x08 1 1 {${device.zigbeeId}} {}" 123 | ] 124 | return configCmds 125 | } 126 | -------------------------------------------------------------------------------- /VirtualButtons/SmartenIt-ZBWS3B.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 Button Remote Zigbee ZBWS3 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 | * in compliance with the License. You may obtain a copy of the License at: 6 | * 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 11 | * for the specific language governing permissions and limitations under the License. 12 | * 13 | * Original code by GilbertChan, modified by obycode to add "numButtons" 14 | * Thanks to Seth Jansen @sjansen for original contributions 15 | */ 16 | metadata { 17 | definition (name: "3 Button Remote (ZBWS3)", namespace: "thegilbertchan", author: "Gilbert Chan") { 18 | capability "Button" 19 | capability "Configuration" 20 | 21 | attribute "button2","ENUM",["released","pressed"] 22 | attribute "button3","ENUM",["released","pressed"] 23 | attribute "numButtons", "STRING" 24 | 25 | fingerprint endpointId: "03", profileId: "0104", deviceId: "0000", deviceVersion: "00", inClusters: "03 0000 0003 0007", outClusters: "01 0006" 26 | 27 | } 28 | // Contributors 29 | 30 | // simulator metadata 31 | simulator { 32 | } 33 | 34 | // UI tile definitions 35 | tiles { 36 | 37 | standardTile("button1", "device.button", width: 1, height: 1) { 38 | state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") 39 | state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") 40 | } 41 | 42 | standardTile("button2", "device.button2", width: 1, height: 1) { 43 | state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") 44 | state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") 45 | } 46 | 47 | standardTile("button3", "device.button3", width: 1, height: 1) { 48 | state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") 49 | state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") 50 | } 51 | 52 | main (["button3", "button2", "button1"]) 53 | details (["button3", "button2", "button1"]) 54 | } 55 | } 56 | 57 | // Parse incoming device messages to generate events 58 | def parse(String description) { 59 | log.debug "Parse description $description" 60 | def name = null 61 | def value = null 62 | if (description?.startsWith("catchall: 0104 0006 01")) { 63 | name = "1" 64 | def currentST = device.currentState("button")?.value 65 | log.debug "Button 1 pushed" 66 | 67 | } 68 | else if (description?.startsWith("catchall: 0104 0006 02")) { 69 | name = "2" 70 | def currentST = device.currentState("button2")?.value 71 | log.debug "Button 2 pushed" 72 | 73 | } 74 | else if (description?.startsWith("catchall: 0104 0006 03")) { 75 | name = "3" 76 | def currentST = device.currentState("button3")?.value 77 | log.debug "Button 3 pushed" 78 | } 79 | 80 | def result = createEvent(name: "button", value: "pushed", data: [buttonNumber: name], descriptionText: "$device.displayName button $name was pushed", isStateChange: true) 81 | log.debug "Parse returned ${result?.descriptionText}" 82 | 83 | 84 | return result 85 | } 86 | 87 | 88 | def parseDescriptionAsMap(description) { 89 | (description - "read attr - ").split(",").inject([:]) { map, param -> 90 | def nameAndValue = param.split(":") 91 | map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] 92 | } 93 | } 94 | 95 | private getFPoint(String FPointHex){ // Parsh out hex string from Value: 4089999a 96 | Long i = Long.parseLong(FPointHex, 16) // Convert Hex String to Long 97 | Float f = Float.intBitsToFloat(i.intValue()) // Convert IEEE 754 Single-Precison floating point 98 | log.debug "converted floating point value: ${f}" 99 | def result = f 100 | 101 | return result 102 | } 103 | 104 | 105 | // Commands to device 106 | 107 | def configure() { 108 | // Set the number of buttons to 3 109 | updateState("numButtons", "3") 110 | 111 | log.debug "Binding SEP 0x01 DEP 0x01 Cluster 0x0006 On/Off cluster to hub" 112 | def configCmds = [ 113 | 114 | "zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0x0006 {${device.zigbeeId}} {}", "delay 500", 115 | "zdo bind 0x${device.deviceNetworkId} 0x02 0x01 0x0006 {${device.zigbeeId}} {}", "delay 500", 116 | "zdo bind 0x${device.deviceNetworkId} 0x03 0x01 0x0006 {${device.zigbeeId}} {}", "delay 1500", 117 | ] 118 | log.info "Sending ZigBee Bind commands to 3 Button Switch" 119 | 120 | return configCmds 121 | } 122 | 123 | // Update State 124 | // Store mode and settings 125 | def updateState(String name, String value) { 126 | state[name] = value 127 | device.updateDataValue(name, value) 128 | } 129 | -------------------------------------------------------------------------------- /VirtualButtons/VirtualButton.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Virtual Button 3 | * 4 | * Copyright 2015 obycode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | metadata { 17 | definition (name: "Virtual Button", namespace: "com.obycode", author: "obycode") { 18 | capability "Button" 19 | capability "Sensor" 20 | 21 | command "push" 22 | command "hold" 23 | command "release" 24 | } 25 | 26 | simulator { 27 | // TODO: define status and reply messages here 28 | } 29 | 30 | tiles { 31 | standardTile("button", "device.button", canChangeIcon: true, inactiveLabel: false, width: 2, height: 2) { 32 | state "default", label: '', icon: "st.secondary.off", action: "push" 33 | state "pressed", label: 'Pressed', icon: "st.illuminance.illuminance.dark", backgroundColor: "#66ccff", action: "release" 34 | state "held", label: 'Held', icon: "st.illuminance.illuminance.light", backgroundColor: "#0066ff", action: "release" 35 | } 36 | 37 | main "button" 38 | details(["button"]) 39 | } 40 | } 41 | 42 | // parse events into attributes 43 | def parse(String description) { 44 | log.debug "Parsing '${description}'" 45 | if (description == "updated") { 46 | sendEvent(name: "button", value: "released") 47 | } 48 | } 49 | 50 | // handle commands 51 | def push() { 52 | log.debug "Executing 'push'" 53 | sendEvent(name: "button", value: "pushed", /*data: [buttonNumber: button], descriptionText: "$device.displayName button $button was pressed",*/ isStateChange: true) 54 | } 55 | 56 | def hold() { 57 | log.debug "Executing 'hold'" 58 | sendEvent(name: "button", value: "held", /*data: [buttonNumber: button], descriptionText: "$device.displayName button $button was held",*/ isStateChange: true) 59 | } 60 | 61 | def release() { 62 | log.debug "Executing 'release'" 63 | sendEvent(name: "button", value: "default", /*data: [buttonNumber: button], descriptionText: "$device.displayName button $button was held",*/ isStateChange: true) 64 | } 65 | -------------------------------------------------------------------------------- /VirtualButtons/VirtualButtons.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Virtual Buttons for Multi-Button Controllers (ex. Minimote or ZWN-SC7) 3 | * 4 | * Author: obycode 5 | * Date Created: 2015-05-13 6 | * 7 | */ 8 | definition( 9 | name: "Virtual Buttons", 10 | namespace: "com.obycode", 11 | author: "obycode", 12 | description: "Create virtual single button devices for each button of your multi-button device (ex. Minimote or ZWN-SC7)", 13 | category: "Convenience", 14 | iconUrl: "http://cdn.device-icons.smartthings.com/unknown/zwave/remote-controller.png", 15 | iconX2Url: "http://cdn.device-icons.smartthings.com/unknown/zwave/remote-controller@2x.png" 16 | ) 17 | 18 | preferences { 19 | section ("Select your button controller: ") { 20 | input "buttonDevice", "capability.button", title: "Which?", multiple: false, required: true 21 | } 22 | } 23 | 24 | def installed() { 25 | initialize() 26 | } 27 | 28 | def updated() { 29 | } 30 | 31 | def initialize() { 32 | def numButtons = buttonDevice.currentValue("numButtons").toInteger() 33 | log.info "Creating $numButtons virtual buttons" 34 | // Create the virtual buttons 35 | (1..numButtons).each { 36 | def d = addChildDevice("com.obycode", "Virtual Button", buttonDevice.id + ":" + it.toString(), null, [label:buttonDevice.displayName + " " + it.toString(), name:"Virtual Button", completedSetup: true]) 37 | } 38 | 39 | // Subscribe to the button events 40 | subscribe(buttonDevice, "button", buttonEvent) 41 | } 42 | 43 | def uninstalled() { 44 | unsubscribe() 45 | def delete = getChildDevices() 46 | delete.each { 47 | deleteChildDevice(it.deviceNetworkId) 48 | } 49 | } 50 | 51 | def buttonEvent(evt) { 52 | log.debug "buttonEvent: $evt.name $evt.value ($evt.data)" 53 | def buttonNumber = evt.jsonData.buttonNumber 54 | 55 | def buttonId = buttonDevice.id + ":" + buttonNumber 56 | def children = getChildDevices() 57 | def childButton = children.find{ d -> d.deviceNetworkId == buttonId } 58 | 59 | switch (evt.value) { 60 | case "pushed": 61 | log.debug "pushing the virtual button" 62 | childButton.push() 63 | break 64 | case "held": 65 | log.debug "holding the virtual button" 66 | childButton.hold() 67 | break 68 | case "default": 69 | log.debug "releasing the virtual button" 70 | childButton.release() 71 | break 72 | default: 73 | log.debug "Unknown event: $evt.value" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /VirtualButtons/ZWN-SC7.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * ZWN-SC7 Enerwave 7 Button Scene Controller 3 | * 4 | * Author: Matt Frank based on VRCS Button Controller by Brian Dahlem, based on SmartThings Button Controller 5 | * Date Created: 2014-12-18 6 | * Last Updated: 2015-02-13 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 9 | * in compliance with the License. You may obtain a copy of the License at: 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 14 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 15 | * for the specific language governing permissions and limitations under the License. 16 | * 17 | */ 18 | 19 | metadata { 20 | // Automatically generated. Make future change here. 21 | definition (name: "ZWN-SC7 Enerwave 7 Button Scene Controller", namespace: "mattjfrank", author: "Matt Frank") { 22 | capability "Actuator" 23 | capability "Button" 24 | capability "Configuration" 25 | capability "Indicator" 26 | capability "Sensor" 27 | 28 | attribute "currentButton", "STRING" 29 | attribute "numButtons", "STRING" 30 | 31 | fingerprint deviceId: "0x0202", inClusters:"0x21, 0x2D, 0x85, 0x86, 0x72" 32 | fingerprint deviceId: "0x0202", inClusters:"0x2D, 0x85, 0x86, 0x72" 33 | } 34 | 35 | simulator { 36 | status "button 1 pushed": "command: 2B01, payload: 01 FF" 37 | status "button 2 pushed": "command: 2B01, payload: 02 FF" 38 | status "button 3 pushed": "command: 2B01, payload: 03 FF" 39 | status "button 4 pushed": "command: 2B01, payload: 04 FF" 40 | status "button 5 pushed": "command: 2B01, payload: 05 FF" 41 | status "button 6 pushed": "command: 2B01, payload: 06 FF" 42 | status "button 7 pushed": "command: 2B01, payload: 07 FF" 43 | status "button released": "command: 2C02, payload: 00" 44 | } 45 | 46 | tiles { 47 | standardTile("button", "device.button", width: 2, height: 2) { 48 | state "default", label: " ", icon: "st.unknown.zwave.remote-controller", backgroundColor: "#ffffff" 49 | state "button 1", label: "1", icon: "st.Weather.weather14", backgroundColor: "#79b821" 50 | state "button 2", label: "2", icon: "st.Weather.weather14", backgroundColor: "#79b821" 51 | state "button 3", label: "3", icon: "st.Weather.weather14", backgroundColor: "#79b821" 52 | state "button 4", label: "4", icon: "st.Weather.weather14", backgroundColor: "#79b821" 53 | state "button 5", label: "5", icon: "st.Weather.weather14", backgroundColor: "#79b821" 54 | state "button 6", label: "6", icon: "st.Weather.weather14", backgroundColor: "#79b821" 55 | state "button 7", label: "7", icon: "st.Weather.weather14", backgroundColor: "#79b821" 56 | } 57 | 58 | // Configure button. Syncronize the device capabilities that the UI provides 59 | standardTile("configure", "device.configure", inactiveLabel: false, decoration: "flat") { 60 | state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure" 61 | } 62 | 63 | main "button" 64 | details (["button", "configure"]) 65 | } 66 | } 67 | 68 | // parse events into attributes 69 | def parse(String description) { 70 | log.debug "Parsing '${description}'" 71 | 72 | def result = null 73 | def cmd = zwave.parse(description) 74 | if (cmd) { 75 | result = zwaveEvent(cmd) 76 | } 77 | 78 | return result 79 | } 80 | 81 | // Handle a button being pressed 82 | def buttonEvent(button) { 83 | button = button as Integer 84 | def result = [] 85 | 86 | updateState("currentButton", "$button") 87 | 88 | if (button > 0) { 89 | // update the device state, recording the button press 90 | result << createEvent(name: "button", value: "pushed", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was pushed", isStateChange: true) 91 | 92 | // turn off the button LED 93 | result << response(zwave.sceneActuatorConfV1.sceneActuatorConfReport(dimmingDuration: 255, level: 255, sceneId: 0)) 94 | } 95 | else { 96 | // update the device state, recording the button press 97 | result << createEvent(name: "button", value: "default", descriptionText: "$device.displayName button was released", isStateChange: true) 98 | 99 | result << response(zwave.sceneActuatorConfV1.sceneActuatorConfReport(dimmingDuration: 255, level: 255, sceneId: 0)) 100 | } 101 | 102 | result 103 | } 104 | 105 | // A zwave command for a button press was received 106 | def zwaveEvent(physicalgraph.zwave.commands.sceneactivationv1.SceneActivationSet cmd) { 107 | 108 | // The controller likes to repeat the command... ignore repeats 109 | if (state.lastScene == cmd.sceneId && (state.repeatCount < 4) && (now() - state.repeatStart < 1000)) { 110 | log.debug "Button ${cmd.sceneId} repeat ${state.repeatCount}x ${now()}" 111 | state.repeatCount = state.repeatCount + 1 112 | createEvent([:]) 113 | } 114 | else { 115 | // If the button was really pressed, store the new scene and handle the button press 116 | state.lastScene = cmd.sceneId 117 | state.lastLevel = 0 118 | state.repeatCount = 0 119 | state.repeatStart = now() 120 | 121 | buttonEvent(cmd.sceneId) 122 | } 123 | } 124 | 125 | // A scene command was received -- it's probably scene 0, so treat it like a button release 126 | def zwaveEvent(physicalgraph.zwave.commands.sceneactuatorconfv1.SceneActuatorConfGet cmd) { 127 | buttonEvent(cmd.sceneId) 128 | } 129 | 130 | // The controller sent a scene activation report. Log it, but it really shouldn't happen. 131 | def zwaveEvent(physicalgraph.zwave.commands.sceneactuatorconfv1.SceneActuatorConfReport cmd) { 132 | log.debug "Scene activation report" 133 | log.debug "Scene ${cmd.sceneId} set to ${cmd.level}" 134 | 135 | createEvent([:]) 136 | } 137 | 138 | 139 | // Configuration Reports are replys to configuration value requests... If we knew what configuration parameters 140 | // to request this could be very helpful. 141 | def zwaveEvent(physicalgraph.zwave.commands.configurationv1.ConfigurationReport cmd) { 142 | createEvent([:]) 143 | } 144 | 145 | // The VRC supports hail commands, but I haven't seen them. 146 | def zwaveEvent(physicalgraph.zwave.commands.hailv1.Hail cmd) { 147 | createEvent([name: "hail", value: "hail", descriptionText: "Switch button was pressed", displayed: false]) 148 | } 149 | 150 | // Update manufacturer information when it is reported 151 | def zwaveEvent(physicalgraph.zwave.commands.manufacturerspecificv2.ManufacturerSpecificReport cmd) { 152 | if (state.manufacturer != cmd.manufacturerName) { 153 | updateDataValue("manufacturer", cmd.manufacturerName) 154 | } 155 | 156 | createEvent([:]) 157 | } 158 | 159 | // Association Groupings Reports tell us how many groupings the device supports. This equates to the number of 160 | // buttons/scenes in the VRCS 161 | def zwaveEvent(physicalgraph.zwave.commands.associationv2.AssociationGroupingsReport cmd) { 162 | def response = [] 163 | 164 | log.debug "${getDataByName("numButtons")} buttons stored" 165 | if (getDataByName("numButtons") != "$cmd.supportedGroupings") { 166 | updateState("numButtons", "$cmd.supportedGroupings") 167 | log.debug "${cmd.supportedGroupings} groups available" 168 | response << createEvent(name: "numButtons", value: cmd.supportedGroupings, displayed: false) 169 | 170 | response << associateHub() 171 | } 172 | else { 173 | response << createEvent(name: "numButtons", value: cmd.supportedGroupings, displayed: false) 174 | } 175 | 176 | return response 177 | } 178 | 179 | 180 | // Handles all Z-Wave commands we don't know we are interested in 181 | def zwaveEvent(physicalgraph.zwave.Command cmd) { 182 | createEvent([:]) 183 | } 184 | 185 | // handle commands 186 | 187 | // Create a list of the configuration commands to send to the device 188 | def configurationCmds() { 189 | // Always check the manufacturer and the number of groupings allowed 190 | def commands = [ 191 | zwave.manufacturerSpecificV1.manufacturerSpecificGet().format(), 192 | zwave.associationV1.associationGroupingsGet().format(), 193 | zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:1, sceneId:1).format(), 194 | zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:2, sceneId:2).format(), 195 | zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:3, sceneId:3).format(), 196 | zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:4, sceneId:4).format(), 197 | zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:5, sceneId:5).format(), 198 | zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:6, sceneId:6).format(), 199 | zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:7, sceneId:7).format() 200 | ] 201 | 202 | commands << associateHub() 203 | 204 | delayBetween(commands) 205 | } 206 | 207 | // Configure the device 208 | def configure() { 209 | def cmd=configurationCmds() 210 | log.debug("Sending configuration: ${cmd}") 211 | return cmd 212 | } 213 | 214 | 215 | // 216 | // Associate the hub with the buttons on the device, so we will get status updates 217 | def associateHub() { 218 | def commands = [] 219 | 220 | // Loop through all the buttons on the controller 221 | for (def buttonNum = 1; buttonNum <= integer(getDataByName("numButtons")); buttonNum++) { 222 | 223 | // Associate the hub with the button so we will get status updates 224 | commands << zwave.associationV1.associationSet(groupingIdentifier: buttonNum, nodeId: zwaveHubNodeId).format() 225 | 226 | } 227 | 228 | return commands 229 | } 230 | 231 | // Update State 232 | // Store mode and settings 233 | def updateState(String name, String value) { 234 | state[name] = value 235 | device.updateDataValue(name, value) 236 | } 237 | 238 | // Get Data By Name 239 | // Given the name of a setting/attribute, lookup the setting's value 240 | def getDataByName(String name) { 241 | state[name] ?: device.getDataValue(name) 242 | } 243 | 244 | //Stupid conversions 245 | 246 | // convert a double to an integer 247 | def integer(double v) { 248 | return v.toInteger() 249 | } 250 | 251 | // convert a hex string to integer 252 | def integerhex(String v) { 253 | if (v == null) { 254 | return 0 255 | } 256 | 257 | return Integer.parseInt(v, 16) 258 | } 259 | 260 | // convert a hex string to integer 261 | def integer(String v) { 262 | if (v == null) { 263 | return 0 264 | } 265 | 266 | return Integer.parseInt(v) 267 | } 268 | -------------------------------------------------------------------------------- /YouLeftTheDoorOpen.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * You left the door open! 3 | * 4 | * Copyright 2014 ObyCode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "You left the door open!", 18 | namespace: "com.obycode", 19 | author: "ObyCode", 20 | description: "Choose a contact sensor from your alarm system (AlarmThing) and get a notification when it is left open for too long.", 21 | category: "Safety & Security", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") 24 | 25 | 26 | preferences { 27 | section("Notify me when the door named...") { 28 | input "theSensor", "string", multiple: false, required: true 29 | } 30 | section("On this alarm...") { 31 | input "theAlarm", "capability.alarm", multiple: false, required: true 32 | } 33 | section("Is left open for more than...") { 34 | input "maxOpenTime", "number", title: "Minutes?" 35 | } 36 | } 37 | 38 | def installed() { 39 | log.debug "Installed with settings: ${settings}" 40 | 41 | initialize() 42 | } 43 | 44 | def updated() { 45 | log.debug "Updated with settings: ${settings}" 46 | 47 | unsubscribe() 48 | initialize() 49 | } 50 | 51 | def initialize() { 52 | subscribe(theAlarm, theSensor, sensorTriggered) 53 | } 54 | 55 | def sensorTriggered(evt) { 56 | if (evt.value == "closed") { 57 | clearStatus() 58 | } 59 | else if (evt.value == "open" && state.status != "scheduled") { 60 | runIn(maxOpenTime * 60, takeAction, [overwrite: false]) 61 | state.status = "scheduled" 62 | } 63 | } 64 | 65 | def takeAction(){ 66 | if (state.status == "scheduled") 67 | { 68 | log.debug "$theSensor was open too long, sending message" 69 | def msg = "Your $theSensor has been open for more than $maxOpenTime minutes!" 70 | sendPush msg 71 | clearStatus() 72 | } else { 73 | log.trace "Status is no longer scheduled. Not sending text." 74 | } 75 | } 76 | 77 | def clearStatus() { 78 | state.status = null 79 | } 80 | -------------------------------------------------------------------------------- /garage-switch.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Control garage with switch 3 | * 4 | * Copyright 2014 ObyCode 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "Control garage with switch", 18 | namespace: "com.obycode", 19 | author: "ObyCode", 20 | description: "Use a z-wave light switch to control your garage. When the switch is pressed down, the garage door will close (if its not already), and likewise, it will open when up is pressed on the switch. Additionally, the indicator light on the switch will tell you if the garage door is open or closed.", 21 | category: "Convenience", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" 24 | ) 25 | 26 | 27 | preferences { 28 | section("Use this switch...") { 29 | input "theSwitch", "capability.switch", multiple: false, required: true 30 | } 31 | section("to control this garage door...") { 32 | input "theOpener", "capability.momentary", multiple: false, required: true 33 | } 34 | section("whose status is given by this sensor...") { 35 | input "theSensor", "capability.threeAxis", multiple: false, required: true 36 | } 37 | } 38 | 39 | def installed() { 40 | log.debug "Installed with settings: ${settings}" 41 | 42 | initialize() 43 | } 44 | 45 | def updated() { 46 | log.debug "Updated with settings: ${settings}" 47 | 48 | unsubscribe() 49 | initialize() 50 | } 51 | 52 | def initialize() { 53 | subscribe(theSwitch, "switch", switchHit) 54 | subscribe(theSensor, "status", statusChanged) 55 | } 56 | 57 | def switchHit(evt) { 58 | log.debug "in switchHit: " + evt.value 59 | def current = theSensor.currentState("status") 60 | if (evt.value == "on") { 61 | if (current.value == "closed") { 62 | theOpener.push() 63 | } 64 | } else { 65 | if (current.value == "open") { 66 | theOpener.push() 67 | } 68 | } 69 | } 70 | 71 | def statusChanged(evt) { 72 | if (evt.value == "open") { 73 | theSwitch.on() 74 | } else if (evt.value == "closed") { 75 | theSwitch.off() 76 | } 77 | } 78 | --------------------------------------------------------------------------------