├── 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('([a-z,A-Z,0-9,\\-,_,:]+>)','\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('([a-z,A-Z,0-9,\\-,_,:]+>)','\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: "
${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('([a-z,A-Z,0-9,\\-,_,:]+>)','\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: "