├── Alexa Connect.groovy ├── Media Renderer Events.groovy ├── MediaRenderer_Connect.groovy ├── MediaRenderer_Connect_Beta.groovy ├── MediaRenderer_Player.groovy ├── README.md ├── Readme_MediaRenderer_Events.md ├── Sonos_Weather_Forecast_External_TTS.groovy ├── Talking_Alarm_Clock_External_TTS ├── WatchDog.groovy └── Weather_Report.groovy /MediaRenderer_Connect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * MediaRenderer Service Manager v 2.1.0 3 | * 4 | * Author: SmartThings - Ulises Mujica 5 | */ 6 | 7 | definition( 8 | name: "MediaRenderer (Connect)", 9 | namespace: "mujica", 10 | author: "SmartThings - Ulises Mujica", 11 | description: "Allows you to control your Media Renderer from the SmartThings app. Perform basic functions like play, pause, stop, change track, and check artist and song name from the Things screen.", 12 | category: "SmartThings Labs", 13 | singleInstance: true, 14 | iconUrl: "https://graph.api.smartthings.com/api/devices/icons/st.secondary.smartapps-tile?displaySize=2x", 15 | iconX2Url: "https://graph.api.smartthings.com/api/devices/icons/st.secondary.smartapps-tile?displaySize=2x" 16 | ) 17 | 18 | preferences { 19 | 20 | page(name: "MainPage", title: "Search and config your Media Renderers", install:true, uninstall: true){ 21 | section("") { 22 | href(name: "discover",title: "Discovery process",required: false,page: "mediaRendererDiscovery",description: "tap to start searching") 23 | } 24 | section("Options", hideable: true, hidden: true) { 25 | input("refreshMRInterval", "number", title:"Enter ip changes interval (min)",defaultValue:"15", required:false) 26 | input("updateMRInterval", "number", title:"Enter refresh players interval (min)",defaultValue:"3", required:false) 27 | } 28 | section("") { 29 | href(name: "watchDog",title: "WatchDog",required: false,page: "watchDogPage",description: "tap to config WatchDog") 30 | } 31 | } 32 | page(name: "mediaRendererDiscovery", title:"Discovery Started!") 33 | page(name: "watchDogPage", title:"WatchDog") 34 | } 35 | 36 | def mediaRendererDiscovery() 37 | { 38 | log.trace "mediaRendererDiscovery() state.subscribe ${state.subscribe}" 39 | if(canInstallLabs()) 40 | { 41 | 42 | int mediaRendererRefreshCount = !state.mediaRendererRefreshCount ? 0 : state.mediaRendererRefreshCount as int 43 | state.mediaRendererRefreshCount = mediaRendererRefreshCount + 1 44 | def refreshInterval = 5 45 | 46 | def options = mediaRenderersDiscovered() ?: [] 47 | 48 | def numFound = options.size() ?: 0 49 | 50 | if(!state.subscribe) { 51 | subscribe(location, null, locationHandler, [filterEvents:false]) 52 | state.subscribe = true 53 | } 54 | 55 | //mediaRenderer discovery request every 5 //25 seconds 56 | if((mediaRendererRefreshCount % 8) == 0) { 57 | discoverMediaRenderers() 58 | } 59 | 60 | //setup.xml request every 3 seconds except on discoveries 61 | if(((mediaRendererRefreshCount % 1) == 0) && ((mediaRendererRefreshCount % 8) != 0)) { 62 | verifyMediaRendererPlayer() 63 | } 64 | 65 | return dynamicPage(name:"mediaRendererDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval) { 66 | section("Please wait while we discover your MediaRenderer. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { 67 | input "selectedMediaRenderer", "enum", required:false, title:"Select Media Renderer (${numFound} found)", multiple:true, options:options 68 | } 69 | } 70 | } 71 | else 72 | { 73 | def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. 74 | 75 | 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".""" 76 | 77 | return dynamicPage(name:"mediaRendererDiscovery", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { 78 | section("Upgrade") { 79 | paragraph "$upgradeNeeded" 80 | } 81 | } 82 | } 83 | } 84 | 85 | private discoverMediaRenderers() 86 | { 87 | sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:MediaRenderer:1", physicalgraph.device.Protocol.LAN)) 88 | } 89 | 90 | 91 | private verifyMediaRendererPlayer() { 92 | def devices = getMediaRendererPlayer().findAll { it?.value?.verified != true } 93 | 94 | devices.each { 95 | verifyMediaRenderer((it?.value?.ip + ":" + it?.value?.port), it?.value?.ssdpPath) 96 | } 97 | } 98 | 99 | private verifyMediaRenderer(String deviceNetworkId, String ssdpPath) { 100 | String ip = getHostAddress(deviceNetworkId) 101 | if(!ssdpPath){ 102 | ssdpPath = "/" 103 | } 104 | log.trace "verifyMediaRenderer($deviceNetworkId, $ssdpPath, $ip)" 105 | sendHubCommand(new physicalgraph.device.HubAction("""GET $ssdpPath HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) 106 | } 107 | 108 | Map mediaRenderersDiscovered() { 109 | def vmediaRenderers = getVerifiedMediaRendererPlayer() 110 | def map = [:] 111 | vmediaRenderers.each { 112 | def value = "${it.value.name}" 113 | def key = it.value.ip + ":" + it.value.port 114 | map["${key}"] = value 115 | } 116 | map 117 | } 118 | 119 | def getMediaRendererPlayer() 120 | { 121 | state.mediaRenderers = state.mediaRenderers ?: [:] 122 | } 123 | 124 | def getVerifiedMediaRendererPlayer() 125 | { 126 | getMediaRendererPlayer().findAll{ it?.value?.verified == true } 127 | } 128 | 129 | def installed() { 130 | log.trace "installed()" 131 | //initialize() 132 | } 133 | 134 | def updated() { 135 | log.trace "updated()" 136 | if (selectedMediaRenderer) addMediaRenderer() 137 | unsubscribe() 138 | state.subscribe = false 139 | unschedule() 140 | clearMediaRenderers() 141 | scheduleTimer() 142 | timerAll() 143 | scheduleActions() 144 | refreshAll() 145 | syncDevices() 146 | subscribeToEvents() 147 | } 148 | 149 | def uninstalled() { 150 | def devices = getChildDevices() 151 | devices.each { 152 | deleteChildDevice(it.deviceNetworkId) 153 | } 154 | } 155 | 156 | def initialize() { 157 | // remove location subscription aftwards 158 | log.trace "initialize()" 159 | //scheduledRefreshHandler() 160 | } 161 | 162 | 163 | def clearMediaRenderers(){ 164 | log.trace "clearMediaRenderers()" 165 | def devices = getChildDevices() 166 | def player 167 | def players = [:] 168 | devices.each { device -> 169 | player = getMediaRendererPlayer().find{ it?.value?.uuid == device.getDataValue('uuid') } 170 | if (player){ 171 | players << player 172 | } 173 | } 174 | state.mediaRenderers = players 175 | 176 | } 177 | 178 | def scheduledRefreshHandler(){ 179 | 180 | } 181 | 182 | def scheduledTimerHandler() { 183 | timerAll() 184 | } 185 | 186 | def scheduledActionsHandler() { 187 | syncDevices() 188 | //runIn(60, scheduledRefreshHandler) 189 | } 190 | 191 | private scheduleTimer() { 192 | def cron = "0 0/1 * * * ?" 193 | schedule(cron, scheduledTimerHandler) 194 | } 195 | 196 | private scheduleActions() { 197 | def minutes = Math.max(settings.refreshMRInterval.toInteger(),1) 198 | def cron = "0 0/${minutes} * * * ?" 199 | schedule(cron, scheduledActionsHandler) 200 | } 201 | 202 | private syncDevices() { 203 | if(!state.subscribe) { 204 | subscribe(location, null, locationHandler, [filterEvents:false]) 205 | state.subscribe = true 206 | } 207 | discoverMediaRenderers() 208 | } 209 | 210 | private timerAll(){ 211 | state.actionTime = new Date().time 212 | childDevices*.poll() 213 | } 214 | 215 | private refreshAll(){ 216 | childDevices*.refresh() 217 | } 218 | 219 | def addMediaRenderer() { 220 | def players = getVerifiedMediaRendererPlayer() 221 | def runSubscribe = false 222 | selectedMediaRenderer.each { dni -> 223 | def d = getChildDevice(dni) 224 | if(!d) { 225 | def newPlayer = players.find { (it.value.ip + ":" + it.value.port) == dni } 226 | if (newPlayer){ 227 | d = addChildDevice("mujica", "DLNA Player", dni, newPlayer?.value.hub, [label:"${newPlayer?.value.name} Speaker","data":["model":newPlayer?.value.model,"avtcurl":newPlayer?.value.avtcurl,"avteurl":newPlayer?.value.avteurl,"rccurl":newPlayer?.value.rccurl,"rceurl":newPlayer?.value.rceurl,"pcurl":newPlayer?.value.pcurl,"peurl":newPlayer?.value.peurl,"udn":newPlayer?.value.udn,"dni":dni]]) 228 | } 229 | runSubscribe = true 230 | } 231 | } 232 | } 233 | 234 | def locationHandler(evt) { 235 | def description = evt.description 236 | def hub = evt?.hubId 237 | def parsedEvent = parseEventMessage(description) 238 | def msg = parseLanMessage(description) 239 | 240 | if (msg?.headers?.sid) 241 | { 242 | childDevices*.each { childDevice -> 243 | if(childDevice.getDataValue('subscriptionId') == ((msg?.headers?.sid ?:"") - "uuid:")|| childDevice.getDataValue('subscriptionId1') == ((msg?.headers?.sid ?:"") - "uuid:")){ 244 | childDevice.parse(description) 245 | } 246 | } 247 | } 248 | 249 | parsedEvent << ["hub":hub] 250 | 251 | if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:MediaRenderer:1")) 252 | { //SSDP DISCOVERY EVENTS 253 | def mediaRenderers = getMediaRendererPlayer() 254 | 255 | if (!(mediaRenderers."${parsedEvent.ssdpUSN.toString()}")) 256 | { //mediaRenderer does not exist 257 | 258 | mediaRenderers << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] 259 | } 260 | else 261 | { // update the values 262 | 263 | def d = mediaRenderers."${parsedEvent.ssdpUSN.toString()}" 264 | boolean deviceChangedValues = false 265 | if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { 266 | d.ip = parsedEvent.ip 267 | d.port = parsedEvent.port 268 | deviceChangedValues = true 269 | } 270 | if (deviceChangedValues) { 271 | def children = getChildDevices() 272 | children.each { 273 | if (parsedEvent.ssdpUSN.toString().contains(it.getDataValue("udn"))) { 274 | it.setDeviceNetworkId((parsedEvent.ip + ":" + parsedEvent.port)) //could error if device with same dni already exists 275 | it.updateDataValue("dni", (parsedEvent.ip + ":" + parsedEvent.port)) 276 | it.refresh() 277 | log.trace "Updated Device IP" 278 | 279 | } 280 | } 281 | } 282 | } 283 | } 284 | else if (parsedEvent.headers && parsedEvent.body) 285 | { // MEDIARENDER RESPONSES 286 | def headerString = new String(parsedEvent?.headers?.decodeBase64()) 287 | def bodyString = new String(parsedEvent.body.decodeBase64()) 288 | 289 | def type = (headerString =~ /Content-Type:.*/) ? (headerString =~ /Content-Type:.*/)[0] : null 290 | def body 291 | def device 292 | if (bodyString?.contains("xml")) 293 | { // description.xml response (application/xml) 294 | body = new XmlSlurper().parseText(bodyString) 295 | log.trace "MEDIARENDER RESPONSES ${body?.device?.modelName?.text()}" 296 | // Avoid add sonos devices 297 | device = body?.device 298 | body?.device?.deviceList?.device?.each{ 299 | if (it?.deviceType?.text().contains("urn:schemas-upnp-org:device:MediaRenderer:1")) { 300 | device = it 301 | } 302 | } 303 | if ( device?.deviceType?.text().contains("urn:schemas-upnp-org:device:MediaRenderer:1")) 304 | { 305 | def avtcurl = "" 306 | def avteurl = "" 307 | def rccurl = "" 308 | def rceurl = "" 309 | def pcurl = "" 310 | def peurl = "" 311 | 312 | 313 | device?.serviceList?.service?.each{ 314 | if (it?.serviceType?.text().contains("AVTransport")) { 315 | avtcurl = it?.controlURL?.text().startsWith("/")? it?.controlURL.text() : "/" + it?.controlURL.text() 316 | avteurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL.text() : "/" + it?.eventSubURL.text() 317 | } 318 | if (it?.serviceType?.text().contains("RenderingControl")) { 319 | rccurl = it?.controlURL?.text().startsWith("/")? it?.controlURL?.text() : "/" + it?.controlURL?.text() 320 | rceurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL?.text() : "/" + it?.eventSubURL?.text() 321 | } 322 | if (it?.serviceType?.text().contains("Party")) { 323 | pcurl = it?.controlURL?.text().startsWith("/")? it?.controlURL?.text() : "/" + it?.controlURL?.text() 324 | peurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL?.text() : "/" + it?.eventSubURL?.text() 325 | } 326 | 327 | } 328 | 329 | 330 | def mediaRenderers = getMediaRendererPlayer() 331 | def player = mediaRenderers.find {it?.key?.contains(device?.UDN?.text())} 332 | if (player) 333 | { 334 | player.value << [name:device?.friendlyName?.text(),model:device?.modelName?.text(), serialNumber:device?.UDN?.text(), verified: true,avtcurl:avtcurl,avteurl:avteurl,rccurl:rccurl,rceurl:rceurl,pcurl:pcurl,peurl:peurl,udn:device?.UDN?.text()] 335 | } 336 | 337 | } 338 | } 339 | else if(type?.contains("json")) 340 | { //(application/json) 341 | body = new groovy.json.JsonSlurper().parseText(bodyString) 342 | } 343 | } 344 | } 345 | 346 | private def parseEventMessage(Map event) { 347 | //handles mediaRenderer attribute events 348 | return event 349 | } 350 | 351 | private def parseEventMessage(String description) { 352 | def event = [:] 353 | def parts = description.split(',') 354 | parts.each { part -> 355 | part = part.trim() 356 | if (part.startsWith('devicetype:')) { 357 | def valueString = part.split(":")[1].trim() 358 | event.devicetype = valueString 359 | } 360 | else if (part.startsWith('mac:')) { 361 | def valueString = part.split(":")[1].trim() 362 | if (valueString) { 363 | event.mac = valueString 364 | } 365 | } 366 | else if (part.startsWith('networkAddress:')) { 367 | def valueString = part.split(":")[1].trim() 368 | if (valueString) { 369 | event.ip = valueString 370 | } 371 | } 372 | else if (part.startsWith('deviceAddress:')) { 373 | def valueString = part.split(":")[1].trim() 374 | if (valueString) { 375 | event.port = valueString 376 | } 377 | } 378 | else if (part.startsWith('ssdpPath:')) { 379 | def valueString = part.split(":")[1].trim() 380 | if (valueString) { 381 | event.ssdpPath = valueString 382 | } 383 | } 384 | else if (part.startsWith('ssdpUSN:')) { 385 | part -= "ssdpUSN:" 386 | def valueString = part.trim() 387 | if (valueString) { 388 | event.ssdpUSN = valueString 389 | } 390 | } 391 | else if (part.startsWith('ssdpTerm:')) { 392 | part -= "ssdpTerm:" 393 | def valueString = part.trim() 394 | if (valueString) { 395 | event.ssdpTerm = valueString 396 | } 397 | } 398 | else if (part.startsWith('headers')) { 399 | part -= "headers:" 400 | def valueString = part.trim() 401 | if (valueString) { 402 | event.headers = valueString 403 | } 404 | } 405 | else if (part.startsWith('body')) { 406 | part -= "body:" 407 | def valueString = part.trim() 408 | if (valueString) { 409 | event.body = valueString 410 | } 411 | } 412 | } 413 | 414 | if (event.devicetype == "04" && event.ssdpPath =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ && !event.ssdpUSN && !event.ssdpTerm){ 415 | def matcher = event.ssdpPath =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ 416 | def ssdpUSN = matcher[0] 417 | event.ssdpUSN = "uuid:$ssdpUSN::urn:schemas-upnp-org:device:MediaRenderer:1" 418 | event.ssdpTerm = "urn:schemas-upnp-org:device:MediaRenderer:1" 419 | } 420 | event 421 | } 422 | 423 | 424 | /////////CHILD DEVICE METHODS 425 | def parse(childDevice, description) { 426 | def parsedEvent = parseEventMessage(description) 427 | 428 | if (parsedEvent.headers && parsedEvent.body) { 429 | def headerString = new String(parsedEvent.headers.decodeBase64()) 430 | def bodyString = new String(parsedEvent.body.decodeBase64()) 431 | 432 | def body = new groovy.json.JsonSlurper().parseText(bodyString) 433 | } else { 434 | return [] 435 | } 436 | } 437 | 438 | private Integer convertHexToInt(hex) { 439 | Integer.parseInt(hex,16) 440 | } 441 | 442 | private String convertHexToIP(hex) { 443 | [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") 444 | } 445 | 446 | private getHostAddress(d) { 447 | def parts = d.split(":") 448 | def ip = convertHexToIP(parts[0]) 449 | def port = convertHexToInt(parts[1]) 450 | return ip + ":" + port 451 | } 452 | 453 | private Boolean canInstallLabs() 454 | { 455 | return hasAllHubsOver("000.011.00603") 456 | } 457 | 458 | private Boolean hasAllHubsOver(String desiredFirmware) 459 | { 460 | return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } 461 | } 462 | 463 | private List getRealHubFirmwareVersions() 464 | { 465 | return location.hubs*.firmwareVersionString.findAll { it } 466 | } 467 | 468 | 469 | 470 | /*watch dog*/ 471 | 472 | 473 | def watchDogPage() { 474 | dynamicPage(name: "watchDogPage") { 475 | def anythingSet = anythingSet() 476 | 477 | if (anythingSet) { 478 | section("Verify Timer When"){ 479 | ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 480 | ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 481 | ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 482 | ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 483 | ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 484 | ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 485 | ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 486 | ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 487 | ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 488 | ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 489 | ifSet "temperature", "capability.temperatureMeasurement", title: "Temperature", required: false, multiple: true 490 | ifSet "powerMeter", "capability.powerMeter", title: "Power Meter", required: false, multiple: true 491 | ifSet "energyMeter", "capability.energyMeter", title: "Energy", required: false, multiple: true 492 | ifSet "signalStrength", "capability.signalStrength", title: "Signal Strength", required: false, multiple: true 493 | ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 494 | ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true 495 | } 496 | } 497 | def hideable = anythingSet || app.installationState == "COMPLETE" 498 | def sectionTitle = anythingSet ? "Select additional triggers" : "Verify Timer When..." 499 | 500 | section(sectionTitle, hideable: hideable, hidden: true){ 501 | ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 502 | ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 503 | ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 504 | ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 505 | ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 506 | ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 507 | ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 508 | ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 509 | ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 510 | ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 511 | ifUnset "temperature", "capability.temperatureMeasurement", title: "Temperature", required: false, multiple: true 512 | ifUnset "signalStrength", "capability.signalStrength", title: "Signal Strength", required: false, multiple: true 513 | ifUnset "powerMeter", "capability.powerMeter", title: "Power Meter", required: false, multiple: true 514 | ifUnset "energyMeter", "capability.energyMeter", title: "Energy Meter", required: false, multiple: true 515 | ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 516 | ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true 517 | } 518 | } 519 | } 520 | 521 | private anythingSet() { 522 | for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water", "temperature","signalStrength","powerMeter","energyMeter","button1","timeOfDay","triggerModes","timeOfDay"]) { 523 | if (settings[name]) { 524 | return true 525 | } 526 | } 527 | return false 528 | } 529 | 530 | private ifUnset(Map options, String name, String capability) { 531 | if (!settings[name]) { 532 | input(options, name, capability) 533 | } 534 | } 535 | 536 | private ifSet(Map options, String name, String capability) { 537 | if (settings[name]) { 538 | input(options, name, capability) 539 | } 540 | } 541 | 542 | private takeAction(evt) { 543 | def eventTime = new Date().time 544 | if (eventTime > ( 60000 + 3 * 1000 * 60 + state.actionTime?:0)) { 545 | scheduleTimer() 546 | timerAll() 547 | } 548 | } 549 | 550 | def eventHandler(evt) { 551 | takeAction(evt) 552 | } 553 | 554 | def modeChangeHandler(evt) { 555 | if (evt.value in triggerModes) { 556 | eventHandler(evt) 557 | } 558 | } 559 | 560 | def subscribeToEvents() { 561 | //subscribe(app, appTouchHandler) 562 | subscribe(contact, "contact.open", eventHandler) 563 | subscribe(contactClosed, "contact.closed", eventHandler) 564 | subscribe(acceleration, "acceleration.active", eventHandler) 565 | subscribe(motion, "motion.active", eventHandler) 566 | subscribe(mySwitch, "switch.on", eventHandler) 567 | subscribe(mySwitchOff, "switch.off", eventHandler) 568 | subscribe(arrivalPresence, "presence.present", eventHandler) 569 | subscribe(departurePresence, "presence.not present", eventHandler) 570 | subscribe(smoke, "smoke.detected", eventHandler) 571 | subscribe(smoke, "smoke.tested", eventHandler) 572 | subscribe(smoke, "carbonMonoxide.detected", eventHandler) 573 | subscribe(water, "water.wet", eventHandler) 574 | subscribe(temperature, "temperature", eventHandler) 575 | subscribe(powerMeter, "power", eventHandler) 576 | subscribe(energyMeter, "energy", eventHandler) 577 | subscribe(signalStrength, "lqi", eventHandler) 578 | subscribe(signalStrength, "rssi", eventHandler) 579 | subscribe(button1, "button.pushed", eventHandler) 580 | if (triggerModes) { 581 | subscribe(location, modeChangeHandler) 582 | } 583 | } 584 | 585 | def getGXState(){ 586 | childDevices*.refreshParty(4) 587 | } 588 | -------------------------------------------------------------------------------- /MediaRenderer_Connect_Beta.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * MediaRenderer Service Manager v 2.0.3 Beta Chromcast Audio Bubble Server 3 | * 4 | * Author: SmartThings - Ulises Mujica 5 | */ 6 | 7 | definition( 8 | name: "MediaRenderer (Connect)", 9 | namespace: "mujica", 10 | author: "SmartThings - Ulises Mujica", 11 | description: "Allows you to control your Media Renderer from the SmartThings app. Perform basic functions like play, pause, stop, change track, and check artist and song name from the Things screen.", 12 | category: "SmartThings Labs", 13 | singleInstance: true, 14 | iconUrl: "https://graph.api.smartthings.com/api/devices/icons/st.secondary.smartapps-tile?displaySize=2x", 15 | iconX2Url: "https://graph.api.smartthings.com/api/devices/icons/st.secondary.smartapps-tile?displaySize=2x" 16 | ) 17 | 18 | preferences { 19 | page(name: "MainPage", title: "Search and config your Media Renderers", install:true, uninstall: true){ 20 | section("") { 21 | href(name: "discover",title: "Discovery process",required: false,page: "mediaRendererDiscovery",description: "tap to start searching") 22 | } 23 | section("Options", hideable: true, hidden: true) { 24 | input("refreshMRInterval", "number", title:"Enter refresh players interval (min)",defaultValue:"15", required:false) 25 | } 26 | } 27 | page(name: "mediaRendererDiscovery", title:"Discovery Started!") 28 | } 29 | 30 | def mediaRendererDiscovery() 31 | { 32 | log.trace "mediaRendererDiscovery() state.subscribe ${state.subscribe}" 33 | if(canInstallLabs()) 34 | { 35 | 36 | 37 | int mediaRendererRefreshCount = !state.mediaRendererRefreshCount ? 0 : state.mediaRendererRefreshCount as int 38 | state.mediaRendererRefreshCount = mediaRendererRefreshCount + 1 39 | def refreshInterval = 5 40 | 41 | def options = mediaRenderersDiscovered() ?: [] 42 | 43 | def numFound = options.size() ?: 0 44 | 45 | if(!state.subscribe) { 46 | subscribe(location, null, locationHandler, [filterEvents:false]) 47 | state.subscribe = true 48 | } 49 | 50 | //mediaRenderer discovery request every 5 //25 seconds 51 | if((mediaRendererRefreshCount % 8) == 0) { 52 | discoverMediaRenderers() 53 | } 54 | 55 | //setup.xml request every 3 seconds except on discoveries 56 | if(((mediaRendererRefreshCount % 1) == 0) && ((mediaRendererRefreshCount % 8) != 0)) { 57 | verifyMediaRendererPlayer() 58 | } 59 | 60 | return dynamicPage(name:"mediaRendererDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval) { 61 | section("Please wait while we discover your MediaRenderer. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { 62 | input "selectedMediaRenderer", "enum", required:false, title:"Select Media Renderer (${numFound} found)", multiple:true, options:options 63 | } 64 | } 65 | } 66 | else 67 | { 68 | def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. 69 | 70 | 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".""" 71 | 72 | return dynamicPage(name:"mediaRendererDiscovery", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { 73 | section("Upgrade") { 74 | paragraph "$upgradeNeeded" 75 | } 76 | } 77 | } 78 | } 79 | 80 | private discoverMediaRenderers() 81 | { 82 | sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:MediaRenderer:1", physicalgraph.device.Protocol.LAN)) 83 | } 84 | 85 | 86 | private verifyMediaRendererPlayer() { 87 | def devices = getMediaRendererPlayer().findAll { it?.value?.verified != true } 88 | 89 | devices.each { 90 | verifyMediaRenderer((it?.value?.ip + ":" + it?.value?.port), it?.value?.ssdpPath) 91 | } 92 | } 93 | 94 | private verifyMediaRenderer(String deviceNetworkId, String ssdpPath) { 95 | String ip = getHostAddress(deviceNetworkId) 96 | if(!ssdpPath){ 97 | ssdpPath = "/" 98 | } 99 | log.trace "verifyMediaRenderer($deviceNetworkId, $ssdpPath, $ip)" 100 | sendHubCommand(new physicalgraph.device.HubAction("""GET $ssdpPath HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) 101 | } 102 | 103 | Map mediaRenderersDiscovered() { 104 | def vmediaRenderers = getVerifiedMediaRendererPlayer() 105 | def map = [:] 106 | vmediaRenderers.each { 107 | def value = "${it.value.name}" 108 | def key = it.value.ip + ":" + it.value.port + ":" + getUUID(it.value.ssdpUSN) 109 | map["${key}"] = value 110 | } 111 | map 112 | } 113 | 114 | def getMediaRendererPlayer() 115 | { 116 | state.mediaRenderers = state.mediaRenderers ?: [:] 117 | } 118 | 119 | def getVerifiedMediaRendererPlayer() 120 | { 121 | getMediaRendererPlayer().findAll{ it?.value?.verified == true } 122 | } 123 | 124 | def installed() { 125 | log.trace "installed()" 126 | //initialize() 127 | } 128 | 129 | def updated() { 130 | log.trace "updated()" 131 | 132 | 133 | if (selectedMediaRenderer) { 134 | addMediaRenderer() 135 | } 136 | 137 | unsubscribe() 138 | state.subscribe = false 139 | unschedule() 140 | scheduleTimer() 141 | scheduleActions() 142 | 143 | refreshAll() 144 | syncDevices() 145 | } 146 | 147 | def uninstalled() { 148 | def devices = getChildDevices() 149 | devices.each { 150 | deleteChildDevice(it.deviceNetworkId) 151 | } 152 | } 153 | 154 | def initialize() { 155 | // remove location subscription aftwards 156 | log.trace "initialize()" 157 | //scheduledRefreshHandler() 158 | } 159 | 160 | 161 | def scheduledRefreshHandler(){ 162 | 163 | } 164 | 165 | def scheduledTimerHandler() { 166 | timerAll() 167 | } 168 | 169 | def scheduledActionsHandler() { 170 | syncDevices() 171 | //runIn(60, scheduledRefreshHandler) 172 | } 173 | 174 | private scheduleTimer() { 175 | def cron = "0 0/3 * * * ?" 176 | schedule(cron, scheduledTimerHandler) 177 | } 178 | 179 | private scheduleActions() { 180 | def minutes = Math.max(settings.refreshMRInterval.toInteger(),1) 181 | def cron = "0 0/${minutes} * * * ?" 182 | schedule(cron, scheduledActionsHandler) 183 | } 184 | 185 | private syncDevices() { 186 | log.debug "syncDevices()" 187 | if(!state.subscribe) { 188 | subscribe(location, null, locationHandler, [filterEvents:false]) 189 | log.trace "subscribe($location, null, locationHandler, [filterEvents:false])" 190 | state.subscribe = true 191 | } 192 | 193 | discoverMediaRenderers() 194 | } 195 | 196 | private timerAll(){ 197 | childDevices*.poll() 198 | } 199 | 200 | private refreshAll(){ 201 | childDevices*.refresh() 202 | } 203 | 204 | def addMediaRenderer() { 205 | def players = getVerifiedMediaRendererPlayer() 206 | def runSubscribe = false 207 | selectedMediaRenderer.each { dni -> 208 | def d = getChildDevice(dni) 209 | if(!d) { 210 | def newPlayer = players.find { (it.value.ip + ":" + it.value.port + ":" + getUUID(it.value.ssdpUSN) ) == dni } 211 | if (newPlayer){ 212 | d = addChildDevice("mujica", "DLNA Player", dni, newPlayer?.value.hub, [label:"${newPlayer?.value.name} Speaker","data":["model":newPlayer?.value.model,"avtcurl":newPlayer?.value.avtcurl,"avteurl":newPlayer?.value.avteurl,"rccurl":newPlayer?.value.rccurl,"rceurl":newPlayer?.value.rceurl,"udn":newPlayer?.value.udn,"dni":dni]]) 213 | } 214 | runSubscribe = true 215 | } 216 | } 217 | } 218 | 219 | def locationHandler(evt) { 220 | def description = evt.description 221 | def hub = evt?.hubId 222 | def parsedEvent = parseEventMessage(description) 223 | def msg = parseLanMessage(description) 224 | 225 | if (msg?.headers?.sid) 226 | { 227 | childDevices*.each { childDevice -> 228 | if(childDevice.getDataValue('subscriptionId') == ((msg?.headers?.sid ?:"") - "uuid:")|| childDevice.getDataValue('subscriptionId1') == ((msg?.headers?.sid ?:"") - "uuid:")){ 229 | childDevice.parse(description) 230 | } 231 | } 232 | } 233 | 234 | 235 | parsedEvent << ["hub":hub] 236 | 237 | if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:MediaRenderer:1")) 238 | { //SSDP DISCOVERY EVENTS 239 | log.debug "MediaRenderer device found" + parsedEvent 240 | def mediaRenderers = getMediaRendererPlayer() 241 | 242 | 243 | if (!(mediaRenderers."${parsedEvent.ssdpUSN.toString()}")) 244 | { //mediaRenderer does not exist 245 | mediaRenderers << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] 246 | } 247 | else 248 | { // update the values 249 | 250 | def d = mediaRenderers."${parsedEvent.ssdpUSN.toString()}" 251 | boolean deviceChangedValues = false 252 | if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { 253 | d.ip = parsedEvent.ip 254 | d.port = parsedEvent.port 255 | deviceChangedValues = true 256 | } 257 | if (deviceChangedValues) { 258 | def children = getChildDevices() 259 | children.each { 260 | if (parsedEvent.ssdpUSN.toString().contains(it.getDataValue("udn"))) { 261 | it.setDeviceNetworkId((parsedEvent.ip + ":" + parsedEvent.port + ":" + getUUID(parsedEvent.ssdpUSN.toString()))) //could error if device with same dni already exists 262 | it.updateDataValue("dni", (parsedEvent.ip + ":" + parsedEvent.port + ":" + getUUID(parsedEvent.ssdpUSN.toString()))) 263 | it.refresh() 264 | log.trace "Updated Device IP" 265 | 266 | } 267 | } 268 | } 269 | } 270 | } 271 | else if (parsedEvent.headers && parsedEvent.body) 272 | { // MEDIARENDER RESPONSES 273 | def headerString = new String(parsedEvent?.headers?.decodeBase64()) 274 | def bodyString = new String(parsedEvent.body.decodeBase64()) 275 | 276 | def type = (headerString =~ /Content-Type:.*/) ? (headerString =~ /Content-Type:.*/)[0] : null 277 | def body 278 | def device 279 | if (bodyString?.contains("xml")) 280 | { // description.xml response (application/xml) 281 | body = new XmlSlurper().parseText(bodyString) 282 | 283 | // Avoid add sonos devices 284 | device = body?.device 285 | body?.device?.deviceList?.device?.each{ 286 | if (it?.deviceType?.text().contains("urn:schemas-upnp-org:device:MediaRenderer:1")) { 287 | device = it 288 | } 289 | } 290 | if ( device?.deviceType?.text().contains("urn:schemas-upnp-org:device:MediaRenderer:1")) 291 | { 292 | def avtcurl = "" 293 | def avteurl = "" 294 | def rccurl = "" 295 | def rceurl = "" 296 | 297 | 298 | device?.serviceList?.service?.each{ 299 | if (it?.serviceType?.text().contains("AVTransport")) { 300 | avtcurl = it?.controlURL?.text().startsWith("/")? it?.controlURL.text() : "/" + it?.controlURL.text() 301 | avteurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL.text() : "/" + it?.eventSubURL.text() 302 | } 303 | else if (it?.serviceType?.text().contains("RenderingControl")) { 304 | rccurl = it?.controlURL?.text().startsWith("/")? it?.controlURL?.text() : "/" + it?.controlURL?.text() 305 | rceurl = it?.eventSubURL?.text().startsWith("/")? it?.eventSubURL?.text() : "/" + it?.eventSubURL?.text() 306 | } 307 | } 308 | 309 | 310 | def mediaRenderers = getMediaRendererPlayer() 311 | def player = mediaRenderers.find {it?.key?.contains(device?.UDN?.text())} 312 | if (player) 313 | { 314 | player.value << [name:device?.friendlyName?.text(),model:device?.modelName?.text(), serialNumber:device?.UDN?.text(), verified: true,avtcurl:avtcurl,avteurl:avteurl,rccurl:rccurl,rceurl:rceurl,udn:device?.UDN?.text()] 315 | } 316 | 317 | } 318 | } 319 | else if(type?.contains("json")) 320 | { //(application/json) 321 | body = new groovy.json.JsonSlurper().parseText(bodyString) 322 | } 323 | } 324 | } 325 | 326 | private def parseEventMessage(Map event) { 327 | //handles mediaRenderer attribute events 328 | return event 329 | } 330 | 331 | private def parseEventMessage(String description) { 332 | def event = [:] 333 | def parts = description.split(',') 334 | parts.each { part -> 335 | part = part.trim() 336 | if (part.startsWith('devicetype:')) { 337 | def valueString = part.split(":")[1].trim() 338 | event.devicetype = valueString 339 | } 340 | else if (part.startsWith('mac:')) { 341 | def valueString = part.split(":")[1].trim() 342 | if (valueString) { 343 | event.mac = valueString 344 | } 345 | } 346 | else if (part.startsWith('networkAddress:')) { 347 | def valueString = part.split(":")[1].trim() 348 | if (valueString) { 349 | event.ip = valueString 350 | } 351 | } 352 | else if (part.startsWith('deviceAddress:')) { 353 | def valueString = part.split(":")[1].trim() 354 | if (valueString) { 355 | event.port = valueString 356 | } 357 | } 358 | else if (part.startsWith('ssdpPath:')) { 359 | def valueString = part.split(":")[1].trim() 360 | if (valueString) { 361 | event.ssdpPath = valueString 362 | } 363 | } 364 | else if (part.startsWith('ssdpUSN:')) { 365 | part -= "ssdpUSN:" 366 | def valueString = part.trim() 367 | if (valueString) { 368 | event.ssdpUSN = valueString 369 | } 370 | } 371 | else if (part.startsWith('ssdpTerm:')) { 372 | part -= "ssdpTerm:" 373 | def valueString = part.trim() 374 | if (valueString) { 375 | event.ssdpTerm = valueString 376 | } 377 | } 378 | else if (part.startsWith('headers')) { 379 | part -= "headers:" 380 | def valueString = part.trim() 381 | if (valueString) { 382 | event.headers = valueString 383 | } 384 | } 385 | else if (part.startsWith('body')) { 386 | part -= "body:" 387 | def valueString = part.trim() 388 | if (valueString) { 389 | event.body = valueString 390 | } 391 | } 392 | } 393 | 394 | if (event.devicetype == "04" && event.ssdpPath =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ && !event.ssdpUSN && !event.ssdpTerm){ 395 | def matcher = event.ssdpPath =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ 396 | def ssdpUSN = matcher[0] 397 | event.ssdpUSN = "uuid:$ssdpUSN::urn:schemas-upnp-org:device:MediaRenderer:1" 398 | event.ssdpTerm = "urn:schemas-upnp-org:device:MediaRenderer:1" 399 | } 400 | event 401 | } 402 | 403 | 404 | /////////CHILD DEVICE METHODS 405 | def parse(childDevice, description) { 406 | def parsedEvent = parseEventMessage(description) 407 | 408 | if (parsedEvent.headers && parsedEvent.body) { 409 | def headerString = new String(parsedEvent.headers.decodeBase64()) 410 | def bodyString = new String(parsedEvent.body.decodeBase64()) 411 | 412 | def body = new groovy.json.JsonSlurper().parseText(bodyString) 413 | } else { 414 | return [] 415 | } 416 | } 417 | 418 | private Integer convertHexToInt(hex) { 419 | Integer.parseInt(hex,16) 420 | } 421 | 422 | private String convertHexToIP(hex) { 423 | [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") 424 | } 425 | 426 | private getHostAddress(d) { 427 | def parts = d.split(":") 428 | def ip = convertHexToIP(parts[0]) 429 | def port = convertHexToInt(parts[1]) 430 | return ip + ":" + port 431 | } 432 | private getUUID(d) { 433 | if (d =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ ){ 434 | def matcher = d =~ /[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/ 435 | matcher[0] 436 | }else{ 437 | "ffffffff-fffffffff-ffff-ffffffffffff" 438 | } 439 | } 440 | private Boolean canInstallLabs() 441 | { 442 | return hasAllHubsOver("000.011.00603") 443 | } 444 | 445 | private Boolean hasAllHubsOver(String desiredFirmware) 446 | { 447 | return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } 448 | } 449 | 450 | private List getRealHubFirmwareVersions() 451 | { 452 | return location.hubs*.firmwareVersionString.findAll { it } 453 | } 454 | -------------------------------------------------------------------------------- /MediaRenderer_Player.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * MediaRenderer Player v2.5.2 3 | * 4 | * Author: SmartThings - Ulises Mujica (Ule) 5 | * 6 | * Fix Metadata 7 | * Fix Radio Stations 8 | */ 9 | 10 | 11 | preferences { 12 | input(name: "customDelay", type: "enum", title: "Delay before msg (seconds)", options: ["0","1","2","3","4","5"]) 13 | input(name: "actionsDelay", type: "enum", title: "Delay between actions (seconds)", options: ["0","1","2","3"]) 14 | input "noDelay", "bool", title: "Avoid Secure Delay", required: false, defaultValue: false 15 | input(name: "refreshFrequency", type: "enum", title: "Refresh frequency (minutes)", options:[0:"Auto",3:"3",5:"5",10:"10",15:"15",20:"20"]) 16 | input "externalTTS", "bool", title: "Use External Text to Speech", required: false, defaultValue: false 17 | input "ttsApiKey", "text", title: "TTS Key", required: false 18 | input(name: "genre", type: "enum", title: "Music Genre", defaultValue:"Smooth Jazz", options:getGenres()) 19 | input "useGenres", "bool", title: "Multiple Genres Instead of Genre", required: false, defaultValue: false 20 | input "genres", "text", title: "Multiple Genres, Write Exact Like Music Genre List", required: false, description:"genre1,genre2,genre3" 21 | } 22 | metadata { 23 | // Automatically generated. Make future change here. 24 | definition (name: "DLNA Player", namespace: "mujica", author: "SmartThings-Ulises Mujica") { 25 | capability "Actuator" 26 | capability "Switch" 27 | capability "Refresh" 28 | capability "Sensor" 29 | capability "Music Player" 30 | capability "Polling" 31 | capability "Speech Synthesis" 32 | 33 | attribute "model", "string" 34 | attribute "trackUri", "string" 35 | attribute "transportUri", "string" 36 | attribute "trackNumber", "string" 37 | attribute "doNotDisturb", "string" 38 | attribute "btnMode", "string" 39 | attribute "udn", "string" 40 | attribute "partyState", "string" 41 | attribute "x_NumberOfListeners", "string" 42 | attribute "singerSessionID", "string" 43 | attribute "sessionID", "string" 44 | 45 | 46 | 47 | 48 | 49 | command "subscribe" 50 | command "getVolume" 51 | command "getCurrentMedia" 52 | command "getCurrentStatus" 53 | command "seek" 54 | command "unsubscribe" 55 | command "setLocalLevel", ["number"] 56 | command "tileSetLevel", ["number"] 57 | command "playTrackAtVolume", ["string","number"] 58 | command "playTrackAndResume", ["string","number","number"] 59 | command "playTextAndResume", ["string","number"] 60 | command "playTrackAndRestore", ["string","number","number"] 61 | command "playTextAndRestore", ["string","number"] 62 | command "playSoundAndTrack", ["string","number","json_object","number"] 63 | command "playTextAndResume", ["string","json_object","number"] 64 | command "setDoNotDisturb", ["string"] 65 | command "switchDoNotDisturb" 66 | command "switchBtnMode" 67 | command "speak", ["string"] 68 | command "playTrack", ["string","string"] 69 | command "playStation", ["number","number"] 70 | command "previousStation" 71 | command "nextStation" 72 | command "previousGenre" 73 | command "nextGenre" 74 | command "party", ["string"] 75 | } 76 | 77 | // Main 78 | standardTile("main", "device.status", width: 1, height: 1, canChangeIcon: true) { 79 | state "playing", label:'Playing', action:"music Player.stop", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#79b821" 80 | state "stopped", label:'Stopped', action:"music Player.play", icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 81 | state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" 82 | state "no_media_present", label:'No Media', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 83 | state "no_media_present_listening", label:'Listening', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 84 | state "no_device_present", label:'No Present', icon:"st.Electronics.electronics16", backgroundColor:"#b6b6b4" 85 | state "grouped", label:'Grouped', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 86 | } 87 | /* 88 | // Row 1 89 | standardTile("nextTrack", "device.status", width: 1, height: 1, decoration: "flat") { 90 | state "next", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff" 91 | } 92 | standardTile("play", "device.status", width: 1, height: 1, decoration: "flat") { 93 | state "default", label:'', action:"music Player.play", icon:"st.sonos.play-btn", nextState:"playing", backgroundColor:"#ffffff" 94 | } 95 | standardTile("previousTrack", "device.status", width: 1, height: 1, decoration: "flat") { 96 | state "previous", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff" 97 | } 98 | */ 99 | // Row 1a 100 | standardTile("nextTrack", "device.btnMode", width: 1, height: 1, decoration: "flat") { 101 | state "default", label:'', action:"nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff",nextState:"default" 102 | state "station", label:'Next Station', action:"nextStation", icon:"http://urbansa.com/icons/next-btn@2x.png", backgroundColor:"#ffffff",nextState:"station" 103 | state "genre", label:'Next Genre', action:"nextGenre", icon:"http://urbansa.com/icons/next-btn@2x.png", backgroundColor:"#ffffff",nextState:"genre" 104 | } 105 | standardTile("play", "device.btnMode", width: 1, height: 1, decoration: "flat") { 106 | state "default", label:'', action:"play", icon:"st.sonos.play-btn", nextState:"default", backgroundColor:"#ffffff" 107 | state "station", label:'Play Station', action:"playStation", icon:"http://urbansa.com/icons/play-btn@2x.png", nextState:"station", backgroundColor:"#ffffff" 108 | state "genre", label:'Play Station', action:"playStation", icon:"http://urbansa.com/icons/play-btn@2x.png", nextState:"genre", backgroundColor:"#ffffff" 109 | } 110 | standardTile("previousTrack", "device.btnMode", width: 1, height: 1, decoration: "flat") { 111 | state "default", label:'', action:"previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff",nextState:"default" 112 | state "station", label:'Prev Station', action:"previousStation", icon:"http://urbansa.com/icons/previous-btn@2x.png", backgroundColor:"#ffffff",nextState:"station" 113 | state "genre", label:'Prev Genre', action:"previousGenre", icon:"http://urbansa.com/icons/previous-btn@2x.png", backgroundColor:"#ffffff",nextState:"genre" 114 | } 115 | 116 | // Row 2 117 | standardTile("status", "device.status", width: 1, height: 1, decoration: "flat", canChangeIcon: true) { 118 | state "playing", label:'Playing', action:"music Player.stop", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#ffffff" 119 | state "stopped", label:'Stopped', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" 120 | state "no_media_present", label:'No Media', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 121 | state "no_media_present_listening", label:'Listening', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 122 | state "no_device_present", label:'No Present', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" 123 | state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" 124 | } 125 | standardTile("stop", "device.status", width: 1, height: 1, decoration: "flat") { 126 | state "default", label:'', action:"music Player.stop", icon:"st.sonos.stop-btn", backgroundColor:"#ffffff" 127 | state "grouped", label:'', action:"music Player.stop", icon:"st.sonos.stop-btn", backgroundColor:"#ffffff" 128 | } 129 | standardTile("mute", "device.mute", inactiveLabel: false, decoration: "flat") { 130 | state "unmuted", label:"", action:"music Player.mute", icon:"st.custom.sonos.unmuted", backgroundColor:"#ffffff", nextState:"muted" 131 | state "muted", label:"", action:"music Player.unmute", icon:"st.custom.sonos.muted", backgroundColor:"#ffffff", nextState:"unmuted" 132 | } 133 | 134 | // Row 3 135 | controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 1, inactiveLabel: false) { 136 | state "level", action:"tileSetLevel", backgroundColor:"#ffffff" 137 | } 138 | 139 | // Row 4 140 | valueTile("currentSong", "device.trackDescription", inactiveLabel: true, height:1, width:3, decoration: "flat") { 141 | state "default", label:'${currentValue}', backgroundColor:"#ffffff" 142 | } 143 | 144 | 145 | // Row 5 146 | standardTile("refreshPlayer", "device.status", inactiveLabel: false, decoration: "flat") { 147 | state "default", label:"", action:"refresh", icon:"st.secondary.refresh", backgroundColor:"#ffffff" 148 | } 149 | standardTile("doNotDisturb", "device.doNotDisturb", width: 1, height: 1, decoration: "flat", canChangeIcon: true) { 150 | state "off", label:"MSG Enabled", action:"switchDoNotDisturb", icon:"st.alarm.beep.beep",nextState:"on" 151 | state "on", label:"MSG Disabled", action:"switchDoNotDisturb", icon:"st.custom.sonos.muted",nextState:"on_playing" 152 | state "on_playing", label:"MSG on Stopped", action:"switchDoNotDisturb", icon:"st.alarm.beep.beep",nextState:"off_playing" 153 | state "off_playing", label:"MSG on Playing", action:"switchDoNotDisturb", icon:"st.alarm.beep.beep",nextState:"off" 154 | } 155 | standardTile("btnMode", "device.btnMode", width: 1, height: 1, decoration: "flat", canChangeIcon: true) { 156 | state "default", label:"Normal", action:"switchBtnMode", icon:"st.Electronics.electronics14",nextState:"station" 157 | state "station", label:"Station", action:"switchBtnMode", icon:"st.Entertainment.entertainment2",nextState:"genre" 158 | state "genre", label:"Genre", action:"switchBtnMode", icon:"st.Electronics.electronics1",nextState:"normal" 159 | } 160 | 161 | standardTile("partyState", "device.partyState", width: 1, height: 1, decoration: "flat", canChangeIcon: true) { 162 | state "default", label:'Not Supported', icon:"st.Electronics.electronics19", backgroundColor:"#ffffff" 163 | state "SINGING", label:'Singing', icon:"st.Electronics.electronics19", backgroundColor:"#ffffff" 164 | state "IDLE", label:'Idle', icon:"st.Electronics.electronics19", backgroundColor:"#ffffff" 165 | state "LISTENING", label:'Listening', icon:"st.Electronics.electronics19", backgroundColor:"#ffffff" 166 | } 167 | 168 | main "main" 169 | 170 | details([ 171 | "previousTrack","play","nextTrack", 172 | /* "previousTrackGenre","playGenre","nextTrackGenre",*/ 173 | "levelSliderControl","stop","mute", 174 | "currentSong", 175 | "status", "doNotDisturb","btnMode", 176 | "partyState","refreshPlayer" 177 | ]) 178 | } 179 | 180 | 181 | def parse(description) { 182 | def results = [] 183 | try { 184 | def msg = parseLanMessage(description) 185 | if (msg.headers) 186 | { 187 | def hdr = msg.header.split('\n')[0] 188 | if (hdr.size() > 36) { 189 | hdr = hdr[0..35] + "..." 190 | } 191 | 192 | def sid = "" 193 | if (msg.headers["sid"]) 194 | { 195 | sid = msg.headers["sid"] 196 | sid -= "uuid:" 197 | sid = sid.trim() 198 | } 199 | 200 | if (!msg.body && msg.headers["timeout"] && sid ) { 201 | updateSid(sid) 202 | } 203 | else if (msg.xml) { 204 | def node 205 | 206 | // Process response to propertyset 207 | node = msg.xml?.property 208 | if(node?.X_NumberOfListeners?.text()) sendEvent(name: "x_NumberOfListeners", value: node.X_NumberOfListeners.text(), description: "$device.displayName x_NumberOfListeners is ${node.X_NumberOfListeners.text()}") 209 | if(node?.X_PartyState?.text()) sendEvent(name: "partyState", value: node.X_PartyState.text(), description: "$device.displayName x_PartyState is ${node.X_PartyState.text()}") 210 | 211 | 212 | node = msg.xml?.Body.X_StartResponse 213 | if(node?.SingerSessionID?.text()) sendEvent(name: "singerSessionID", value: node.SingerSessionID.text(), description: "$device.displayName SingerSessionID is ${node.SingerSessionID.text()}") 214 | //if(node?.X_PartyState?.text()) sendEvent(name: "x_PartyState", value: node.X_PartyState.text(), description: "$device.displayName x_PartyState is ${node.X_PartyState.text()}") 215 | 216 | 217 | 218 | // Process response to getState() 219 | node = msg.xml?.Body?.X_GetStateResponse 220 | if(node?.PartyState?.text()) sendEvent(name: "partyState", value: node.PartyState.text(), description: "$device.displayName partyState is ${node.PartyState.text()}") 221 | if(node?.PartyMode?.text()) sendEvent(name: "partyMode", value: node.PartyMode.text(), description: "$device.displayName partyMode is ${node.PartyMode.text()}") 222 | if(node?.PartySong?.text()) sendEvent(name: "partySong", value: node.PartySong.text(), description: "$device.displayName PartySong is ${node.PartySong.text()}") 223 | if(node?.SessionID?.text()) sendEvent(name: "sessionID", value: node.SessionID.text(), description: "$device.displayName SessionID is ${node.SessionID.text()}") 224 | if(node?.SingerSessionID?.text()) sendEvent(name: "singerSessionID", value: node.SingerSessionID.text(), description: "$device.displayName SingerSessionID is ${node.SingerSessionID.text()}") 225 | if(node?.ListenerList?.text()) sendEvent(name: "listenerList", value: node.ListenerList.text(), description: "$device.displayName ListenerList is ${node.ListenerList.text()}") 226 | if(node?.SingerUUID?.text()) sendEvent(name: "singerUUID", value: node.SingerUUID.text(), description: "$device.displayName SingerUUID is ${node.SingerUUID.text()}") 227 | 228 | 229 | // Process response to getVolume() 230 | node = msg.xml.Body.GetVolumeResponse 231 | if (node.size()) { 232 | def currentVolume = node.CurrentVolume.text() 233 | if (currentVolume) { 234 | sendEvent(name: "level", value: currentVolume, description: "$device.displayName Volume is $currentVolume") 235 | } 236 | } 237 | // Process response to getCurrentStatus() 238 | node = msg.xml.Body.GetTransportInfoResponse 239 | if (node.size()) { 240 | def currentStatus = statusText(node.CurrentTransportState.text()) 241 | if (currentStatus) { 242 | state.lastStatusTime = new Date().time 243 | if (currentStatus != "TRANSITIONING") { 244 | if (currentStatus == "no_media_present" && device.currentValue("partyState") == "LISTENING") currentStatus = statusText('LISTENING') 245 | sendEvent(name: "status", value: currentStatus, displayed: false) 246 | sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false) 247 | 248 | } 249 | } 250 | } 251 | node = msg.xml.Body.GetTransportSettingsResponse 252 | if (node.size()) { 253 | def currentPlayMode = node.PlayMode.text() 254 | 255 | if (currentPlayMode) { 256 | sendEvent(name: "playMode", value: currentPlayMode, description: "$device.displayName Play Mode is $currentPlayMode") 257 | } 258 | } 259 | node = msg.xml.Body.GetPositionInfoResponse 260 | if (node.size()) { 261 | 262 | } 263 | 264 | 265 | // Process subscription update 266 | node = msg.xml.property.LastChange 267 | if (node?.text()?.size()>40 && node?.text()?.contains("0 ? transportUri.replaceAll(/fii%3d.*?%26/, "fii%3d${Math.max(trackNumber.toInteger() - 1,0)}%26") : transportUri 325 | state.transportUri = transportUri 326 | 327 | def trackMeta = xml1.InstanceID.CurrentTrackMetaData.'@val'.text() 328 | trackMeta = trackMeta == "NOT_IMPLEMENTED" ? "":trackMeta 329 | def transportMeta = xml1.InstanceID.AVTransportURIMetaData.'@val'.text() 330 | transportMeta = transportMeta == "NOT_IMPLEMENTED" ? "":transportMeta 331 | 332 | if (trackMeta || transportMeta) { 333 | def metaDataLoad = trackMeta ? trackMeta : transportMeta 334 | 335 | def metaData = metaDataLoad?.startsWith("$metaDataLoad": metaDataLoad 336 | metaData = metaData.contains("dlna:dlna") && !metaData.contains("xmlns:dlna") ? metaData.replace(")','\n$1\n') 410 | .replaceAll('()','\n$1\n') 411 | .replaceAll('\n\n','\n').encodeAsHTML() : "" 412 | results << createEvent( 413 | name: "mediaRendererMessage", 414 | value: "${msg.body.encodeAsMD5()}", 415 | description: description, 416 | descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", 417 | data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", 418 | isStateChange: false, displayed: false) 419 | } 420 | } 421 | else { 422 | def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') 423 | .replaceAll('()','\n$1\n') 424 | .replaceAll('\n\n','\n').encodeAsHTML() : "" 425 | results << createEvent( 426 | name: "unknownMessage", 427 | value: "${msg.body.encodeAsMD5()}", 428 | description: description, 429 | descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", 430 | data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", 431 | isStateChange: true, displayed: true) 432 | } 433 | } 434 | } 435 | catch (Throwable t) { 436 | //results << createEvent(name: "parseError", value: "$t") 437 | sendEvent(name: "parseError", value: "$t", description: description) 438 | throw t 439 | } 440 | results 441 | } 442 | 443 | def installed() { 444 | sendEvent(name:"model",value:getDataValue("model"),isStateChange:true) 445 | sendEvent(name:"udn",value:getDataValue("udn"),isStateChange:true) 446 | // def result = [] 447 | // result << delayAction(10000) 448 | // result << refresh() 449 | // result.flatten() 450 | } 451 | 452 | def on(){ 453 | play() 454 | } 455 | 456 | def off(){ 457 | stop() 458 | } 459 | 460 | def poll() { 461 | timer() 462 | } 463 | 464 | def timer(){ 465 | def eventTime = new Date().time 466 | state.gapTime = refreshFrequency > 0 ? (refreshFrequency? (refreshFrequency as Integer):0) * 60 : (parent.updateMRInterval? (parent.updateMRInterval as Integer):0) * 60 467 | if ((state.lastRefreshTime ?:0) + (state.lastChange ? state.gapTime * 1000 : 300000) <= eventTime ){ 468 | refresh() 469 | } 470 | } 471 | def refresh() { 472 | sendEvent(name:"udn",value:getDataValue("udn"),isStateChange:true) 473 | def eventTime = new Date().time 474 | 475 | if( eventTime > state.secureEventTime ?:0) 476 | { 477 | //installed() 478 | if ((state.lastRefreshTime ?: 0) > (state.lastStatusTime ?:0)){ 479 | sendEvent(name: "status", value: "no_device_present", data: "no_device_present", displayed: false) 480 | } 481 | state.lastRefreshTime = eventTime 482 | def result = [] 483 | //result << unsubscribe() 484 | //result << delayAction(10000) 485 | result << subscribe() 486 | result << getCurrentStatus() 487 | result << getVolume() 488 | result << getPlayMode() 489 | result << getCurrentMedia() 490 | result << getXState() 491 | result.flatten() 492 | }else{ 493 | log.trace "Refresh skipped" 494 | } 495 | 496 | } 497 | 498 | def setLevel(val) 499 | { 500 | setLocalLevel(val) 501 | } 502 | 503 | def tileSetLevel(val) 504 | { 505 | setLocalLevel(val) 506 | 507 | } 508 | def setDoNotDisturb(val) 509 | { 510 | sendEvent(name:"doNotDisturb",value:val,isStateChange:true) 511 | } 512 | def setBtnMode(val) 513 | { 514 | sendEvent(name:"btnMode",value:val,isStateChange:true) 515 | } 516 | 517 | // Always sets only this level 518 | def setLocalLevel(val, delay=0) { 519 | def v = Math.max(Math.min(Math.round(val), 100), 0) 520 | def result = [] 521 | if (delay) { 522 | result << delayAction(delay) 523 | } 524 | result << mediaRendererAction("SetVolume", "RenderingControl", getDataValue("rccurl") , [InstanceID:0, Channel:"Master", DesiredVolume:v]) 525 | //result << delayAction(50) 526 | result << mediaRendererAction("GetVolume", "RenderingControl", getDataValue("rccurl"), [InstanceID:0, Channel:"Master"]) 527 | result 528 | } 529 | // Always sets only this level 530 | def setVolume(val) { 531 | mediaRendererAction("SetVolume", "RenderingControl", getDataValue("rccurl") , [InstanceID:0, Channel:"Master", DesiredVolume:Math.max(Math.min(Math.round(val), 100), 0)]) 532 | } 533 | 534 | private childLevel(previousMaster, newMaster, previousChild) 535 | { 536 | if (previousMaster) { 537 | if (previousChild) { 538 | Math.round(previousChild * (newMaster / previousMaster)) 539 | } 540 | else { 541 | newMaster 542 | } 543 | } 544 | else { 545 | newMaster 546 | } 547 | } 548 | 549 | 550 | def play() { 551 | mediaRendererAction("Play") 552 | 553 | } 554 | 555 | def stop() { 556 | mediaRendererAction("Stop") 557 | } 558 | 559 | def pause() { 560 | mediaRendererAction("Pause") 561 | } 562 | 563 | def nextTrack() { 564 | mediaRendererAction("Next") 565 | } 566 | 567 | def previousTrack() { 568 | mediaRendererAction("Previous") 569 | } 570 | 571 | def nextStation() { 572 | playStation(1,0) 573 | } 574 | 575 | def previousStation() { 576 | playStation(-1,0) 577 | } 578 | 579 | def nextGenre() { 580 | playStation(0,1) 581 | } 582 | 583 | def previousGenre() { 584 | playStation(0,-1) 585 | } 586 | 587 | 588 | def seek(trackNumber) { 589 | mediaRendererAction("Seek", "AVTransport", getDataValue("avtcurl") , [InstanceID:0, Unit:"TRACK_NR", Target:trackNumber]) 590 | } 591 | 592 | def mute() 593 | { 594 | // TODO - handle like volume? 595 | mediaRendererAction("SetMute", "RenderingControl", getDataValue("rccurl"), [InstanceID:0, Channel:"Master", DesiredMute:1]) 596 | } 597 | 598 | def unmute() 599 | { 600 | // TODO - handle like volume? 601 | mediaRendererAction("SetMute", "RenderingControl", getDataValue("rccurl"), [InstanceID:0, Channel:"Master", DesiredMute:0]) 602 | } 603 | 604 | def setPlayMode(mode) 605 | { 606 | mediaRendererAction("SetPlayMode", [InstanceID:0, NewPlayMode:mode]) 607 | } 608 | def switchDoNotDisturb(){ 609 | switch(device.currentValue("doNotDisturb")) { 610 | case "off": 611 | setDoNotDisturb("on") 612 | break 613 | case "on": 614 | setDoNotDisturb("on_playing") 615 | break 616 | case "on_playing": 617 | setDoNotDisturb("off_playing") 618 | break 619 | default: 620 | setDoNotDisturb("off") 621 | } 622 | } 623 | def switchBtnMode(){ 624 | switch(device.currentValue("btnMode")) { 625 | case "normal": 626 | setBtnMode("station") 627 | break 628 | case "station": 629 | setBtnMode("genre") 630 | break 631 | case "genre": 632 | setBtnMode("normal") 633 | break 634 | default: 635 | setBtnMode("station") 636 | } 637 | } 638 | 639 | 640 | def playByMode(uri, duration, volume,newTrack,mode) { 641 | def playTrack = false 642 | def restoreVolume = true 643 | def eventTime = new Date().time 644 | def track = device.currentState("trackData")?.jsonValue 645 | def currentVolume = device.currentState("level")?.integerValue 646 | def currentStatus = device.currentValue("status") 647 | def currentPlayMode = device.currentValue("playMode") 648 | def currentDoNotDisturb = device.currentValue("doNotDisturb") 649 | def level = volume as Integer 650 | def actionsDelayTime = actionsDelay ? (actionsDelay as Integer) * 1000 :0 651 | def result = [] 652 | duration = duration ? (duration as Integer) : 0 653 | switch(mode) { 654 | case 1: 655 | playTrack = currentStatus == "playing" ? true : false 656 | break 657 | case 3: 658 | track = newTrack 659 | restoreVolume = false 660 | playTrack = !track?.uri?.startsWith("http://127.0.0.1") ? true : false 661 | break 662 | } 663 | if( !(currentDoNotDisturb == "on_playing" && currentStatus == "playing" ) && !(currentDoNotDisturb == "off_playing" && currentStatus != "playing" ) && currentDoNotDisturb != "on" ){//&& eventTime > state.secureEventTime ?:0 664 | if (uri){ 665 | uri = cleanUri(uri) 666 | uri = uri + ( uri.contains("?") ? "&":"?") + "ts=$eventTime" 667 | 668 | result << mediaRendererAction("Stop") 669 | result << delayAction(delayControl(1000) + actionsDelayTime) 670 | 671 | if (level && (currentVolume != level )) { 672 | //if(actionsDelayTime > 0){result << delayAction(actionsDelayTime)} 673 | result << setVolume(level) 674 | result << delayAction(delayControl(2200) + actionsDelayTime) 675 | } 676 | if (currentPlayMode != "NORMAL") { 677 | result << setPlayMode("NORMAL") 678 | result << delayAction(delayControl(2000) + actionsDelayTime) 679 | } 680 | result << setTrack(uri) 681 | result << delayAction(delayControl(2000) + actionsDelayTime) 682 | result << mediaRendererAction("Play") 683 | if (duration < 2){ 684 | def matcher = uri =~ /[^\/]+.mp3/ 685 | if (matcher){duration = Math.max(Math.round(matcher[0].length()/8),2)} 686 | } 687 | def delayTime = (duration * 1000) + 3000 688 | delayTime = customDelay ? ((customDelay as Integer) * 1000) + delayTime : delayTime 689 | state.secureEventTime = eventTime + delayTime + 7000 690 | result << delayAction(delayTime) 691 | } 692 | if (track ) { 693 | 694 | result << mediaRendererAction("Stop") 695 | result << delayAction(delayControl(1000) + actionsDelayTime) 696 | 697 | if (level && restoreVolume ) { 698 | result << setVolume(currentVolume) 699 | result << delayAction(delayControl(2200) + actionsDelayTime) 700 | } 701 | if (currentPlayMode != "NORMAL") { 702 | result << setPlayMode(currentPlayMode) 703 | result << delayAction(delayControl(2000) + actionsDelayTime) 704 | } 705 | if (!track.uri.startsWith("http://127.0.0.1")){ 706 | result << setTrack(track) 707 | result << delayAction(delayControl(2000) + actionsDelayTime) 708 | } 709 | if (playTrack) { 710 | if (!track.uri.startsWith("http://127.0.0.1")){ 711 | result << mediaRendererAction("Play") 712 | }else{ 713 | result << mediaRendererAction("Next") 714 | } 715 | if (!state.lastChange){ 716 | result << getCurrentMedia() 717 | } 718 | }else{ 719 | result << mediaRendererAction("Stop") 720 | } 721 | } 722 | result = result.flatten() 723 | } 724 | else{ 725 | log.trace "previous notification in progress or Do Not Disturb Activated" 726 | } 727 | result 728 | } 729 | 730 | def playTextAndResume(text, volume=null){ 731 | def sound = externalTTS ? textToSpeechT(text) : safeTextToSpeech(text) 732 | playByMode(sound.uri, Math.max((sound.duration as Integer),1), volume, null, 1) 733 | } 734 | def playTextAndRestore(text, volume=null){ 735 | def sound = externalTTS ? textToSpeechT(text) : safeTextToSpeech(text) 736 | playByMode(sound.uri, Math.max((sound.duration as Integer),1), volume, null, 2) 737 | } 738 | def playTextAndTrack(text, trackData, volume=null){ 739 | def sound = externalTTS ? textToSpeechT(text) : safeTextToSpeech(text) 740 | playByMode(sound.uri, Math.max((sound.duration as Integer),1), volume, trackData, 3) 741 | } 742 | def playTrackAndResume(uri, duration, volume=null) { 743 | playByMode(uri, duration, volume, null, 1) 744 | } 745 | def playTrackAndRestore(uri, duration, volume=null) { 746 | playByMode(uri, duration, volume, null, 2) 747 | } 748 | def playSoundAndTrack(uri, duration, trackData, volume=null) { 749 | playByMode(uri, duration, volume, trackData, 3) 750 | } 751 | 752 | def playTrackAtVolume(String uri, volume) { 753 | playByMode(uri, 0, volume, null, 3) 754 | } 755 | 756 | def playTrack(String uri, metaData="") { 757 | def actionsDelayTime = actionsDelay ? (actionsDelay as Integer) * 1000 :0 758 | def result = [] 759 | 760 | // result << mediaRendererAction("Stop") 761 | // result << delayAction(100 + actionsDelayTime) 762 | result << setTrack(uri, metaData) 763 | result << delayAction(1000 + actionsDelayTime) 764 | result << mediaRendererAction("Play") 765 | /* if (!state.lastChange){ 766 | result << delayAction(2000 + actionsDelayTime) 767 | result << getCurrentMedia() 768 | }*/ 769 | result.flatten() 770 | } 771 | 772 | def playTrack(Map trackData) { 773 | def result = [] 774 | result << setTrack(trackData) 775 | result << delayAction(1000 + actionsDelayTime) 776 | result << mediaRendererAction("Play") 777 | if (!state.lastChange){ 778 | result << getCurrentMedia() 779 | } 780 | result.flatten() 781 | } 782 | 783 | 784 | 785 | def setTrack(Map trackData) { 786 | setTrack(trackData.uri, trackData?.metaData) 787 | } 788 | 789 | def setTrack(String uri, metaData="") 790 | { 791 | metaData = metaData? cleanXML(metaData) : null 792 | metaData = metaData?:"object.item.audioItem.musicTrackSmartThings CatalogSmartThingshttps://graph.api.smartthings.com/api/devices/icons/st.Entertainment.entertainment2-icn?displaySize=2xSmartThings Message${groovy.xml.XmlUtil.escapeXml(uri)} " 793 | metaData = removeAccents(cleanUri(metaData)) 794 | mediaRendererAction("SetAVTransportURI", [InstanceID:0, CurrentURI:cleanUri(uri),CurrentURIMetaData:""]) 795 | } 796 | 797 | def resumeTrack(Map trackData = null) { 798 | def result = [] 799 | def actionsDelayTime = actionsDelay ? (actionsDelay as Integer) * 1000 :0 800 | result << restoreTrack(trackData) 801 | result << delayAction(2000 + actionsDelayTime) 802 | result << mediaRendererAction("Play") 803 | result.flatten() 804 | } 805 | 806 | def restoreTrack(Map trackData = null) { 807 | def result = [] 808 | def data = trackData 809 | if (!data) { 810 | data = device.currentState("trackData")?.jsonValue 811 | } 812 | if (data) { 813 | result << mediaRendererAction("SetAVTransportURI", [InstanceID:0, CurrentURI:cleanUri(data.uri), CurrentURIMetaData:cleanXML(cleanUri(data.metaData))]) 814 | } 815 | else { 816 | log.warn "Previous track data not found" 817 | } 818 | result 819 | } 820 | 821 | def playText(String msg) { 822 | if (msg?.startsWith("cmd:")){ 823 | 824 | 825 | }else{ 826 | def result = setText(msg) 827 | result << mediaRendererAction("Play") 828 | } 829 | 830 | } 831 | 832 | def setText(String msg) { 833 | def sound = externalTTS ? textToSpeechT(msg) : safeTextToSpeech(msg) 834 | setTrack(sound.uri) 835 | } 836 | 837 | def speak(String msg){ 838 | playTextAndResume(msg, null) 839 | } 840 | 841 | // Custom commands 842 | 843 | def subscribe() { 844 | def result = [] 845 | result << subscribeAction(getDataValue("rceurl")) 846 | result << delayAction(2500) 847 | result << subscribeAction(getDataValue("avteurl")) 848 | result << delayAction(2500) 849 | if (getDataValue("peurl")){ 850 | result << subscribeAction(getDataValue("peurl")) 851 | result << delayAction(2500) 852 | } 853 | result 854 | } 855 | def unsubscribe() { 856 | def result = [ 857 | unsubscribeAction(getDataValue("avteurl"), device.getDataValue('subscriptionId')), 858 | unsubscribeAction(getDataValue("rceurl"), device.getDataValue('subscriptionId')), 859 | unsubscribeAction(getDataValue("avteurl"), device.getDataValue('subscriptionId1')), 860 | unsubscribeAction(getDataValue("rceurl"), device.getDataValue('subscriptionId1')), 861 | ] 862 | //updateDataValue("subscriptionId", "") 863 | //updateDataValue("subscriptionId1", "") 864 | result 865 | } 866 | 867 | def getVolume() 868 | { 869 | mediaRendererAction("GetVolume", "RenderingControl", getDataValue("rccurl"), [InstanceID:0, Channel:"Master"]) 870 | } 871 | 872 | def getPlayMode() 873 | { 874 | mediaRendererAction("GetTransportSettings", [InstanceID:0]) 875 | } 876 | def getCurrentMedia() 877 | { 878 | mediaRendererAction("GetPositionInfo", [InstanceID:0]) 879 | } 880 | 881 | def getCurrentStatus() //transport info 882 | { 883 | mediaRendererAction("GetTransportInfo", [InstanceID:0]) 884 | } 885 | 886 | def getSystemString() 887 | { 888 | mediaRendererAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName:"UMTracking"]) 889 | } 890 | 891 | private messageFilename(String msg) { 892 | msg.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/,'_') 893 | } 894 | 895 | private getCallBackAddress() 896 | { 897 | device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP") 898 | } 899 | 900 | private mediaRendererAction(String action) { 901 | def result 902 | if(action=="Play"){ 903 | result = mediaRendererAction(action, "AVTransport", getDataValue("avtcurl"), [InstanceID:0, Speed:1]) 904 | } 905 | else if (action=="Mute"){ 906 | result = mediaRendererAction("SetMute", "RenderingControl", getDataValue("rccurl"), [InstanceID: 0, Channel:"Master", DesiredMute:1]) 907 | } 908 | else if (action=="UnMute"){ 909 | result = mediaRendererAction("SetMute", "RenderingControl", getDataValue("rccurl"), [InstanceID: 0, Channel:"Master", DesiredMute:0]) 910 | } 911 | else{ 912 | result = mediaRendererAction(action, "AVTransport", getDataValue("avtcurl"), [InstanceID:0]) 913 | } 914 | result 915 | } 916 | 917 | private mediaRendererAction(String action, Map body) { 918 | mediaRendererAction(action, "AVTransport", getDataValue("avtcurl"), body) 919 | } 920 | 921 | private mediaRendererAction(String action, String service, String path, Map body = [InstanceID:0, Speed:1]) { 922 | def urn = service.contains("Party")?"urn:schemas-sony-com:service:$service:1":"urn:schemas-upnp-org:service:$service:1" 923 | def result = new physicalgraph.device.HubSoapAction( 924 | path: path ?: "/MediaRenderer/$service/Control", 925 | urn: urn, 926 | action: action, 927 | body: body, 928 | headers: [Host:getHostAddress(), CONNECTION: "close"] 929 | ) 930 | result 931 | } 932 | 933 | private subscribeAction(path, callbackPath="") { 934 | def address = getCallBackAddress() 935 | def ip = getHostAddress() 936 | 937 | def result = new physicalgraph.device.HubAction( 938 | method: "SUBSCRIBE", 939 | path: path, 940 | headers: [ 941 | HOST: ip, 942 | CALLBACK: "", 943 | NT: "upnp:event", 944 | TIMEOUT: "Second-${state.gapTime ?:300}"]) 945 | result 946 | } 947 | 948 | 949 | private unsubscribeAction(path, sid) { 950 | def ip = getHostAddress() 951 | def result = new physicalgraph.device.HubAction( 952 | method: "UNSUBSCRIBE", 953 | path: path, 954 | headers: [ 955 | HOST: ip, 956 | SID: "uuid:${sid}"]) 957 | result 958 | } 959 | 960 | private delayAction(long time) { 961 | new physicalgraph.device.HubAction("delay $time") 962 | } 963 | 964 | private Integer convertHexToInt(hex) { 965 | Integer.parseInt(hex,16) 966 | } 967 | 968 | private String convertHexToIP(hex) { 969 | [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") 970 | } 971 | 972 | 973 | 974 | private getHostAddress() { 975 | def parts = getDataValue("dni")?.split(":") 976 | def ip = convertHexToIP(parts[0]) 977 | def port = convertHexToInt(parts[1]) 978 | return ip + ":" + port 979 | } 980 | 981 | private statusText(s) { 982 | switch(s) { 983 | case "PLAYING": 984 | return "playing" 985 | case "PAUSED_PLAYBACK": 986 | return "paused" 987 | case "STOPPED": 988 | return "stopped" 989 | case "NO_MEDIA_PRESENT": 990 | return "no_media_present" 991 | case "NO_DEVICE_PRESENT": 992 | return "no_device_present" 993 | case "LISTENING": 994 | return "no_media_present_listening" 995 | default: 996 | return s 997 | } 998 | } 999 | 1000 | private updateSid(sid) { 1001 | if (sid) { 1002 | def sid0 = device.getDataValue('subscriptionId') 1003 | def sid1 = device.getDataValue('subscriptionId1') 1004 | def sidNumber = device.getDataValue('sidNumber') ?: "0" 1005 | if (sidNumber == "0") { 1006 | if (sid != sid1) { 1007 | updateDataValue("subscriptionId", sid) 1008 | updateDataValue("sidNumber", "1") 1009 | } 1010 | } 1011 | else { 1012 | if (sid != sid0) { 1013 | updateDataValue("subscriptionId1", sid) 1014 | updateDataValue("sidNumber", "0") 1015 | } 1016 | } 1017 | } 1018 | } 1019 | 1020 | private dniFromUri(uri) { 1021 | def segs = uri.replaceAll(/http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)\/.+/,'$1').split(":") 1022 | def nums = segs[0].split("\\.") 1023 | (nums.collect{hex(it.toInteger())}.join('') + ':' + hex(segs[-1].toInteger(),4)).toUpperCase() 1024 | } 1025 | 1026 | private hex(value, width=2) { 1027 | def s = new BigInteger(Math.round(value).toString()).toString(16) 1028 | while (s.size() < width) { 1029 | s = "0" + s 1030 | } 1031 | s 1032 | } 1033 | private cleanXML(xml){ 1034 | try { 1035 | if (xml){ 1036 | def parsedXML = parseXml(xml) 1037 | } 1038 | }catch (e) { 1039 | log.debug "Error when parsing XML : " + e 1040 | log.debug metaData 1041 | xml="" 1042 | } 1043 | xml 1044 | } 1045 | private cleanUri(uri) { 1046 | def model = getDataValue("model") 1047 | if (uri){ 1048 | uri = uri.replace("https:","http:") 1049 | if (!model?.toLowerCase()?.contains("sonos")){ 1050 | uri = uri.replace("x-rincon-mp3radio:","http:") 1051 | } 1052 | } 1053 | return uri 1054 | } 1055 | 1056 | 1057 | private textToSpeechT(message){ 1058 | if (message) { 1059 | if (ttsApiKey){ 1060 | [uri: "x-rincon-mp3radio://api.voicerss.org/" + "?key=$ttsApiKey&hl=en-us&r=0&f=48khz_16bit_mono&src=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-" , duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 1061 | }else{ 1062 | message = message.length() >100 ? message[0..90] :message 1063 | [uri: "x-rincon-mp3radio://www.translate.google.com/translate_tts?tl=en&client=t&q=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-", duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 1064 | } 1065 | }else{ 1066 | [uri: "https://s3.amazonaws.com/smartapp-media/tts/633e22db83b7469c960ff1de955295f57915bd9a.mp3", duration: "10"] 1067 | } 1068 | } 1069 | 1070 | private delayControl(time){ 1071 | noDelay?0:time 1072 | } 1073 | 1074 | private safeTextToSpeech(message) { 1075 | message = message?:"You selected the Text to Speach Function but did not enter a Message" 1076 | try { 1077 | textToSpeech(message) 1078 | } 1079 | catch (Throwable t) { 1080 | log.error t 1081 | textToSpeechT(message) 1082 | } 1083 | } 1084 | 1085 | private removeAccents(String s) { 1086 | s = s.replaceAll("[áàâãä]","a"); 1087 | s = s.replaceAll("[éèêë]","e"); 1088 | s = s.replaceAll("[íìîï]","i"); 1089 | s = s.replaceAll("[óòôõö]","o"); 1090 | s = s.replaceAll("[úùûü]","u"); 1091 | s = s.replaceAll("ç","c"); 1092 | 1093 | s = s.replaceAll("[ÁÀÂÃÄ]","A"); 1094 | s = s.replaceAll("[ÉÈÊË]","E"); 1095 | s = s.replaceAll("[ÍÌÎÏ]","I"); 1096 | s = s.replaceAll("[ÓÒÔÕÖ]","O"); 1097 | s = s.replaceAll("[ÚÙÛÜ]","U"); 1098 | s = s.replaceAll("Ç","C"); 1099 | 1100 | return s; 1101 | } 1102 | 1103 | 1104 | def getGenres(){ 1105 | ["<<< Alternative >>>","Adult Alternative","Britpop","Classic Alternative","College","Dancepunk","Dream Pop","Emo","Goth","Grunge","Hardcore","Indie Pop","Indie Rock","Industrial","LoFi","Modern Rock","New Wave","Noise Pop","Post Punk","Power Pop","Punk","Ska","Xtreme","<<< Blues >>>","Acoustic Blues","Cajun and Zydeco","Chicago Blues","Contemporary Blues","Country Blues","Delta Blues","Electric Blues","<<< Classical >>>","Baroque","Chamber","Choral","Classical Period","Early Classical","Impressionist","Modern","Opera","Piano","Romantic","Symphony","<<< Country >>>","Alt Country","Americana","Bluegrass","Classic Country","Contemporary Bluegrass","Contemporary Country","Honky Tonk","Hot Country Hits","Western","<<< Decades >>>","00s","30s","40s","50s","60s","70s","80s","90s","<<< Easy Listening >>>","Exotica","Light Rock","Lounge","Orchestral Pop","Polka","Space Age Pop","<<< Electronic >>>","Acid House","Ambient","Big Beat","Breakbeat","Dance","Demo","Disco","Downtempo","Drum and Bass","Dubstep","Electro","Garage","Hard House","House","IDM","Jungle","Progressive","Techno","Trance","Tribal","Trip Hop","<<< Folk >>>","Alternative Folk","Contemporary Folk","Folk Rock","New Acoustic","Old Time","Traditional Folk","World Folk","<<< Inspirational >>>","Christian","Christian Metal","Christian Rap","Christian Rock","Classic Christian","Contemporary Gospel","Gospel","Praise and Worship","Sermons and Services","Southern Gospel","Traditional Gospel","<<< International >>>","African","Afrikaans","Arabic","Asian","Bollywood","Brazilian","Caribbean","Celtic","Chinese","Creole","European","Filipino","French","German","Greek","Hawaiian and Pacific","Hebrew","Hindi","Indian","Islamic","Japanese","Klezmer","Korean","Mediterranean","Middle Eastern","North American","Russian","Soca","South American","Tamil","Turkish","Worldbeat","Zouk","<<< Jazz >>>","Acid Jazz","Avant Garde","Big Band","Bop","Classic Jazz","Cool Jazz","Fusion","Hard Bop","Latin Jazz","Smooth Jazz","Swing","Vocal Jazz","World Fusion","<<< Latin >>>","Bachata","Banda","Bossa Nova","Cumbia","Flamenco","Latin Dance","Latin Pop","Latin Rap and Hip Hop","Latin Rock","Mariachi","Merengue","Ranchera","Reggaeton","Regional Mexican","Salsa","Samba","Tango","Tejano","Tropicalia","<<< Metal >>>","Black Metal","Classic Metal","Death Metal","Extreme Metal","Grindcore","Hair Metal","Heavy Metal","Metalcore","Power Metal","Progressive Metal","Rap Metal","Thrash Metal","<<< Misc >>>","<<< New Age >>>","Environmental","Ethnic Fusion","Healing","Meditation","Spiritual","<<< Pop >>>","Adult Contemporary","Barbershop","Bubblegum Pop","Dance Pop","Idols","JPOP","KPOP","Oldies","Soft Rock","Teen Pop","Top 40","World Pop","<<< Public Radio >>>","College","News","Sports","Talk","Weather","<<< R&B and Urban >>>","Classic R&B","Contemporary R&B","Doo Wop","Funk","Motown","Neo Soul","Quiet Storm","Soul","Urban Contemporary","<<< Rap >>>","Alternative Rap","Dirty South","East Coast Rap","Freestyle","Gangsta Rap","Hip Hop","Mixtapes","Old School","Turntablism","Underground Hip Hop","West Coast Rap","<<< Reggae >>>","Contemporary Reggae","Dancehall","Dub","Pop Reggae","Ragga","Reggae Roots","Rock Steady","<<< Rock >>>","Adult Album Alternative","British Invasion","Celtic Rock","Classic Rock","Garage Rock","Glam","Hard Rock","Jam Bands","JROCK","Piano Rock","Prog Rock","Psychedelic","Rock & Roll","Rockabilly","Singer and Songwriter","Surf","<>","Anniversary","Birthday","Christmas","Halloween","Hanukkah","Honeymoon","Kwanzaa","Valentine","Wedding","Winter","<<< Soundtracks >>>","Anime","Kids","Original Score","Showtunes","Video Game Music","<<< Talk >>>","BlogTalk","Comedy","Community","Educational","Government","News","Old Time Radio","Other Talk","Political","Scanner","Spoken Word","Sports","Technology","<<< Themes >>>","Adult","Best Of","Chill","Eclectic","Experimental","Female","Heartache","Instrumental","LGBT","Love and Romance","Party Mix","Patriotic","Rainy Day Mix","Reality","Sexy","Shuffle","Travel Mix","Tribute","Trippy","Work Mix"] 1106 | } 1107 | 1108 | def playStation(incStatation = 0, incGenre = 0){ 1109 | def genre 1110 | if (settings["useGenres"]){ 1111 | if (state.selectedGenres != settings["genres"]){ 1112 | state.selectedGenres = settings["genres"] 1113 | state.genres = [] 1114 | settings["genres"]?.tokenize(",").each{item -> 1115 | if (getGenres().collect{it.replaceAll(/<|>| /, "").trim().toLowerCase()}.contains(item.trim().toLowerCase())){ 1116 | state.genres << item.trim() 1117 | } 1118 | } 1119 | log.trace "Genres parsed ${state.genres}" 1120 | } 1121 | if (incGenre == 1 || incGenre == -1) Collections.rotate(state.genres, -incGenre) 1122 | genre = state.genres[0] 1123 | }else{ 1124 | genre = settings["genre"] 1125 | } 1126 | 1127 | 1128 | if (genre){ 1129 | def stations = getStationGenre(genre) 1130 | if (stations){ 1131 | 1132 | if (incStatation == 1 || incStatation == -1) Collections.rotate(state[genre], -incStatation) 1133 | def stationUri = getUriStation(stations[0].keySet()[0]) //"x-rincon-mp3radio://listen.radionomy.com/${radionomyStations[station[0]].key[0]}" 1134 | playTrack(stationUri,"object.item.audioItem.audioBroadcast${groovy.xml.XmlUtil.escapeXml(genre)}SHOUTcasthttp://www.radionomygroup.com/img/shoutcast-logo.png${groovy.xml.XmlUtil.escapeXml(stations[0].values().n[0])}${groovy.xml.XmlUtil.escapeXml(stationUri)} ") 1135 | } 1136 | } 1137 | } 1138 | 1139 | def getUriStation(id){ 1140 | def uri 1141 | def params = [ 1142 | uri: "http://directory.shoutcast.com/Player/GetStreamUrl", 1143 | body: [ station: id], 1144 | contentType: "text/plain", 1145 | ] 1146 | try { 1147 | httpPost(params) { resp -> 1148 | uri = resp.data.text 1149 | if (uri){ 1150 | uri = uri.replaceAll("\"",""); 1151 | } 1152 | } 1153 | } catch (ex) { 1154 | log.debug "something went wrong: $ex" 1155 | } 1156 | uri 1157 | } 1158 | 1159 | def getStationGenre(genre){ 1160 | if (genre){ 1161 | if(!state[genre] || state[genre]?.size() == 0 ){ 1162 | state[genre] = [] 1163 | try { 1164 | def params = [ 1165 | uri: "http://directory.shoutcast.com/Home/BrowseByGenre", 1166 | body: [ 1167 | genrename: genre 1168 | ] 1169 | ] 1170 | httpPostJson(params) { resp -> 1171 | resp.data.any { element -> 1172 | if (!element.IsRadionomy && !element.AACEnabled && element.Bitrate == 128 ){ 1173 | state[genre] << ["${element.ID}":[n:"${element.Name}",a:""]] 1174 | } 1175 | if (state[genre].size() >=10 ){ 1176 | return true // break 1177 | } 1178 | } 1179 | } 1180 | } catch (ex) { 1181 | log.debug "something went wrong: $ex" 1182 | } 1183 | } 1184 | state[genre] 1185 | }else{ 1186 | [] 1187 | } 1188 | } 1189 | 1190 | def getXState() 1191 | { 1192 | mediaRendererAction("X_GetState", "Party", "/Party_Control", [:]) 1193 | } 1194 | def refreshParty(delay){ 1195 | def result = [] 1196 | def actionsDelayTime = delay ? (delay as Integer) * 1000 :0 1197 | result << delayAction(actionsDelayTime) 1198 | result << getXState() 1199 | result.flatten() 1200 | } 1201 | 1202 | def party(list){ 1203 | def pList = "" 1204 | def result = [] 1205 | def actionsDelayTime = actionsDelay ? (actionsDelay as Integer) * 1000 :0 1206 | 1207 | if (list){ 1208 | def nList = list.split(",") as List 1209 | 1210 | if (nList.contains(device.currentValue("udn"))) nList.remove(device.currentValue("udn")) 1211 | if (device.currentValue("partyState")=="SINGING"){ 1212 | def cList = device.currentValue("listenerList").split(",") as List 1213 | def iList = nList.intersect(cList) 1214 | nList = nList - iList 1215 | def rList = cList - iList 1216 | if(nList && getDataValue("pcurl")) result << mediaRendererAction("X_Entry", "Party",getDataValue("pcurl") , [SingerSessionID:device.currentValue("sessionID"),ListenerList:nList.join(",")]) 1217 | result << delayAction(1000 + actionsDelayTime) 1218 | if(rList && getDataValue("pcurl")) result << mediaRendererAction("X_Leave", "Party",getDataValue("pcurl") , [SingerSessionID:device.currentValue("sessionID"),ListenerList:rList.join(",")]) 1219 | 1220 | }else{ 1221 | if (nList && getDataValue("pcurl")) result << mediaRendererAction("X_Start", "Party",getDataValue("pcurl") , [PartyMode:"PARTY",ListenerList:nList.join(",")]) 1222 | } 1223 | }else{ 1224 | if(device.currentValue("partyState")=="SINGING" && getDataValue("pcurl")) result << mediaRendererAction("X_Abort", "Party",getDataValue("pcurl") , [SingerSessionID:device.currentValue("sessionID")]) 1225 | 1226 | } 1227 | if (result){ 1228 | parent.getGXState() 1229 | result.flatten() 1230 | } 1231 | } 1232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DLNA-PLAYER 2 | Generic DLNA Player to Smartthings v 2.0.1 3 | 4 | The **DLNA PLAYER** Device Type allows to [SmartThings](http://www.smartthings.com) to send messages and music to almost any DLNA Media Renderer Device 5 | 6 | Currently there are more than 20 devices confirmed and more than 30 waiting confirmation [List](https://community.smartthings.com/t/working-speakers-20-devices-confirmed-31-waiting-confirmation-last-addition-heos-7-help-us-to-increase-the-list/12107/) 7 | 8 | 9 | The DLNA Player Controls Interface 10 | 11 | 1. Play 12 | 2. Stop 13 | 3. Next 14 | 4. Forward 15 | 5. Mute/Unmute 16 | 6. Control Volume 17 | 7. Show media description 18 | 8. Mode: Msg Enabled, Msg Disabled, Msg On Stopped, Msg on Playing 19 | 20 | 21 | 22 | Its compatible with official smartapps made for sonos speakers 23 | 24 | 1. Music & Sounds : Sonos Control by SmartThings Play or pause your Sonos when certain actions take place in your home. 25 | 2. Sonos Mood Music by SmartThings : Plays a selected song or station. 26 | 3. Sonos Notify with Sound by SmartThings : Play a sound or custom message through your Sonos when the mode changes or other events occur. 27 | 4. Sonos Weather Forecast by SmartThings : Play a weather report through your Sonos when the mode changes or other events occur 28 | 5. Talking Alarm Clock by Michael Struck : Control up to 4 waking schedules using a Sonos speaker as an alarm. 29 | 30 | Unoficial 31 | 32 | Its compatible with unofficial smartapps made music and messages 33 | 34 | 1. Media Renderer Events : Play a custom message, sound or RadioTunes station through your Media Renderer when the mode changes or other events occur. 35 | 2. ... 36 | 37 | 38 | *If you like this app, please consider supporting its development by making a 39 | donation via PayPal.* 40 | 41 | [![PayPal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=A6XBY99S5FECL) 42 | 43 | The **MediaRenderer Connect** search for the Media Renderers devices in your network. 44 | 45 | 46 | 47 | **Installing The MediaRenderer Connect** 48 | 49 | 50 | 51 | Open SmartThings IDE in your web browser and log into your account. 52 | 53 | Click on the "My Smart Apps" section in the navigation bar. 54 | 55 | On your SmartApps page, click on the "+ New SmartApp" button on the right. 56 | 57 | On the "New SmartApp" page, Select the Tab "From Code" , Copy the MediaRenderer_Connect source code from GitHub and paste it into the IDE editor window. 58 | 59 | Click the blue "Create" button at the bottom of the page. An IDE editor window containing device handler template should now open. 60 | 61 | Click the blue "Save" button above the editor window. 62 | 63 | Click the "Publish" button next to it and select "For Me". You have now self-published your SmartApp. 64 | 65 | 66 | 67 | **Installing The Device Type** 68 | 69 | 70 | Open SmartThings IDE in your web browser and log into your account. 71 | 72 | Click on the "My Device Types" section in the navigation bar. 73 | 74 | On your Device Types page, click on the "+ New Device Type" button on the right. 75 | 76 | On the "New Device Type" page, Select the Tab "From Code" , Copy the MediaRenderer_Player source code from GitHub and paste it into the IDE editor window. 77 | 78 | Click the blue "Create" button at the bottom of the page. An IDE editor window containing device handler template should now open. 79 | 80 | Click the blue "Save" button above the editor window. 81 | 82 | Click the "Publish" button next to it and select "For Me". You have now self-published your SmartApp. 83 | 84 | 85 | **Searching the Media Renderers** 86 | 87 | 88 | Open the SmartThings app in your smartphone. 89 | 90 | Select the (+) icon to install new things 91 | 92 | Go to My Apps section and select MediaRenderer Connect 93 | 94 | The MediaRenderer Connect will start to search Your players. 95 | 96 | Once the app will show the quantity of media renderers found, activate all the media renderers found and finish the proccess pressing the "Done Button" in both pages. 97 | 98 | Get back to main window and you should have the new players in your things section, refresh the page if no players appears. 99 | 100 | Revision History 101 | ---------------- 102 | **Version 1.9.5 103 | * protocol fix compatible con x-rincon-mp3radio needed by sonos to play external sources 104 | 105 | **Version 1.9.2 106 | * External TTS Function, allows to use an alternative of TTS build in Function 107 | 108 | **Version 1.9.0 109 | * No device present detection 110 | * Its necesary to update the conecctor too and uninstall,reinstall the players, if you do not have software devices its not necesary to update, but recommended. 111 | 112 | **Version 1.8.0 113 | excluded by bugs 114 | 115 | **Version 1.7.0 116 | * Foobar and Software device autocorrect added 117 | * Its necesary to update the conecctor too and uninstall,reinstall the players, if you do not have software devices its not necesary to update, but recommended. 118 | 119 | **Version 1.6.0 120 | * Speech Synthesis Capability added 121 | 122 | **Version 1.5.6 123 | * Fix TTS delay 124 | * Implemented Msg only when playing and Msg only when no playing 125 | * Several minor fixes 126 | * Fix Resume in control point list (No container) 127 | * Fix Volume Refresh in software MediaRenderers 128 | * Better play event 129 | * Prefix P.L. in container when last song list is display in music apps 130 | * Prefix Unavailable in control point manage song when last song list is display in music apps 131 | 132 | **Version 1.5.0 133 | * Improved Track resume and Container resume 134 | 135 | **Version 1.4.0 136 | * Implemented message in progress 137 | 138 | **Version 1.3.0 139 | * Implemented new Delay before message (Some slow MR need more time to load an external http file) 140 | 141 | **Version 1.2.0 142 | * Implemented new Delay between actions (Some slow MR need more time between actions) 143 | 144 | **Version 1.1.0 145 | * Implemented new Do not Disturb state to avoid messages 146 | 147 | **Version 1.0.1 148 | * Removed Play test button 149 | 150 | **Version 1.0.0. Released 2015-02-13** 151 | * First public release. 152 | 153 | -------------------------------------------------------------------------------- /Readme_MediaRenderer_Events.md: -------------------------------------------------------------------------------- 1 | MediaRenderer Events 2 | -------- 3 | 4 | The **MediaRenderer Events** SmartApp allows you play messages, sounds, Tracks and Radio Stations from Radionomy 5 | [Radionomy](http://www.radionomy.com//). 6 | 7 | [Radionomy](http://www.radionomy.com//) is a music streaming with more than 1000 stations, I have added the most popular, if you want to add other station to list, please contact me 8 | 9 | *If you like this app, please consider supporting its development by making a donation via PayPal.* 10 | 11 | [![PayPal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=A6XBY99S5FECL) 12 | 13 | ### Installation 14 | 15 | 1. Self-publish MadiaRenderer Events SmartApp by creating a new SmartApp in the 16 | [SmartThings IDE](https://graph.api.smartthings.com/ide/apps) and pasting the 17 | [source code](https://raw.githubusercontent.com/SmartThingsUle/DLNA-PLAYER/master/Media Renderer Events.groovy) 18 | in the "From Code" tab. Please refer to the 19 | [SmartThings Developer Documentation](http://docs.smartthings.com/en/latest/index.html) 20 | for more information. 21 | 22 | 2. Self-publish MadiaRenderer player Smart type by creating a new Device Type or Updating in the 23 | [SmartThings IDE](https://graph.api.smartthings.com/ide/apps) and pasting the 24 | [source code](https://raw.githubusercontent.com/SmartThingsUle/DLNA-PLAYER/master/MediaRenderer_Player.groovy) 25 | in the "From Code" tab. Please refer to the 26 | [SmartThings Developer Documentation](http://docs.smartthings.com/en/latest/index.html) 27 | for more information. 28 | 29 | 3. To use RadioTunes stations, you mus get a free account and set the key in the app 30 | 31 | 4. Once you have an Radio Tunes account, go to http://www.radiotunes.com/settings and select "Hardware Player" and "Good (96k MP3)" , You going to see a url like this http://listen.radiotunes.com/public3/hit00s.pls?listen_key=xxxxxxxxxxxxxxxxx 32 | 33 | 5. You can add your key in line 100 of Mediarenderer events SmartApp to avoid writing by the app or input in the smartapp defaultValue: "" 34 | 35 | 36 | MediaRenderer Connect 37 | 38 | ### Revision History 39 | 40 | **Version 1.7.0.** 41 | 42 | * Radionomy Added, many new stations 43 | * RadioTunes Removed 44 | 45 | **Version 1.6.0.** 46 | 47 | * Multiple languages added to voice RSS 48 | * Ivona TTS more reliable 49 | 50 | 51 | **Version 1.5.0.** 52 | 53 | * Beta Ivona TTS 54 | 55 | **Version 1.4.0.** 56 | 57 | * Now more than 200 stations 58 | * Messages with wildcards 59 | 60 | **Version 1.3.0.** 61 | 62 | * Multiple Speakers added 63 | * Now you can select several speakers to send music, messages, track, stations, the sound is not in sync, the satart play depends of the speaker and web response. 64 | 65 | **Version 1.2.0.** 66 | 67 | * New Text to Speech added 68 | * Now you can avoid Text to speech failures, you can add the voicerss.org service, its free, you must to register and get you key 69 | 70 | **Version 1.1.0.** 71 | 72 | * Multiple Radio Station Added, Mode: Loop, Random, Shuffle 73 | * Now you can select your favorites radio strations and play a different station each time an event is detected. 74 | 75 | **Version 1.0.0. Released 10/09/2015** 76 | 77 | * Initial public release. 78 | 79 | 80 | ### License 81 | 82 | Copyright © 2015 83 | 84 | This program is free software: you can redistribute it and/or modify it 85 | under the terms of the GNU General Public License as published by the Free 86 | Software Foundation, either version 3 of the License, or (at your option) 87 | any later version. 88 | 89 | This program is distributed in the hope that it will be useful, but 90 | WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 91 | or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 92 | for more details. 93 | 94 | You should have received a copy of the GNU General Public License along 95 | with this program. If not, see . 96 | -------------------------------------------------------------------------------- /Sonos_Weather_Forecast_External_TTS.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 Weather Forecast 14 | * 15 | * Author: SmartThings 16 | * Date: 2014-1-29 17 | */ 18 | definition( 19 | name: "Sonos Weather Forecast External TTS", 20 | namespace: "smartthings", 21 | author: "SmartThings", 22 | description: "Play a weather report through your Sonos when the mode changes or other events occur", 23 | category: "SmartThings Labs", 24 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos.png", 25 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos@2x.png" 26 | ) 27 | 28 | preferences { 29 | page(name: "mainPage", title: "Play the weather report on your sonos", install: true, uninstall: true) 30 | page(name: "chooseTrack", title: "Select a song or station") 31 | page(name: "ttsKey", title: "Add the Text for Speach Key") 32 | page(name: "timeIntervalInput", title: "Only during a certain time") { 33 | section { 34 | input "starting", "time", title: "Starting", required: false 35 | input "ending", "time", title: "Ending", required: false 36 | } 37 | } 38 | } 39 | 40 | def mainPage() { 41 | dynamicPage(name: "mainPage") { 42 | def anythingSet = anythingSet() 43 | if (anythingSet) { 44 | section("Play weather report when"){ 45 | ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 46 | ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 47 | ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 48 | ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 49 | ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 50 | ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 51 | ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 52 | ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 53 | ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 54 | ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 55 | ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 56 | ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true 57 | ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false 58 | } 59 | } 60 | def hideable = anythingSet || app.installationState == "COMPLETE" 61 | def sectionTitle = anythingSet ? "Select additional triggers" : "Play weather report when..." 62 | 63 | section(sectionTitle, hideable: hideable, hidden: true){ 64 | ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 65 | ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 66 | ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 67 | ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 68 | ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 69 | ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 70 | ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 71 | ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 72 | ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 73 | ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 74 | ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 75 | ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true 76 | ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false 77 | } 78 | section { 79 | input("forecastOptions", "enum", defaultValue: "0", title: "Weather report options", description: "Select one or more", multiple: true, 80 | options: [ 81 | ["0": "Current Conditions"], 82 | ["1": "Today's Forecast"], 83 | ["2": "Tonight's Forecast"], 84 | ["3": "Tomorrow's Forecast"], 85 | ] 86 | ) 87 | } 88 | section { 89 | input "sonos", "capability.musicPlayer", title: "On this Sonos player", required: true,multiple: true 90 | } 91 | section("More options", hideable: true, hidden: true) { 92 | href "ttsKey", title: "Text to Speach Key", description: ttsApiKey, state: ttsApiKey ? "complete" : "incomplete" 93 | input "resumePlaying", "bool", title: "Resume currently playing music after weather report finishes", required: false, defaultValue: true 94 | href "chooseTrack", title: "Or play this music or radio station", description: song ? state.selectedSong?.station : "Tap to set", state: song ? "complete" : "incomplete" 95 | 96 | input "zipCode", "text", title: "Zip Code", required: false 97 | input "volume", "number", title: "Temporarily change volume", description: "0-100%", required: false 98 | input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false 99 | href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" 100 | input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, 101 | options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 102 | if (settings.modes) { 103 | input "modes", "mode", title: "Only when mode is", multiple: true, required: false 104 | } 105 | input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false 106 | } 107 | section([mobileOnly:true]) { 108 | label title: "Assign a name", required: false 109 | mode title: "Set for specific mode(s)" 110 | } 111 | } 112 | } 113 | 114 | def chooseTrack() { 115 | dynamicPage(name: "chooseTrack") { 116 | section{ 117 | input "song","enum",title:"Play this track", required:true, multiple: false, options: songOptions() 118 | } 119 | } 120 | } 121 | 122 | def ttsKey() { 123 | dynamicPage(name: "ttsKey") { 124 | section{ 125 | input "ttsApiKey", "text", title: "TTS Key", required: false 126 | } 127 | section ("Voice RSS provides free Text-to-Speech API as WEB service, allows 350 free request per day with high quality voice") { 128 | href(name: "hrefRegister", 129 | title: "Register", 130 | required: false, 131 | style: "external", 132 | url: "http://www.voicerss.org/registration.aspx", 133 | description: "Register and obtain you TTS Key") 134 | href(name: "hrefKnown", 135 | title: "Known about Voice RSS", 136 | required: false, 137 | style: "external", 138 | url: "http://www.voicerss.org/", 139 | description: "Go to www.voicerss.org") 140 | } 141 | } 142 | } 143 | 144 | private anythingSet() { 145 | for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","timeOfDay","triggerModes"]) { 146 | if (settings[name]) { 147 | return true 148 | } 149 | } 150 | return false 151 | } 152 | 153 | private ifUnset(Map options, String name, String capability) { 154 | if (!settings[name]) { 155 | input(options, name, capability) 156 | } 157 | } 158 | 159 | private ifSet(Map options, String name, String capability) { 160 | if (settings[name]) { 161 | input(options, name, capability) 162 | } 163 | } 164 | 165 | def installed() { 166 | log.debug "Installed with settings: ${settings}" 167 | subscribeToEvents() 168 | } 169 | 170 | def updated() { 171 | log.debug "Updated with settings: ${settings}" 172 | unsubscribe() 173 | unschedule() 174 | subscribeToEvents() 175 | } 176 | 177 | def subscribeToEvents() { 178 | subscribe(app, appTouchHandler) 179 | subscribe(contact, "contact.open", eventHandler) 180 | subscribe(contactClosed, "contact.closed", eventHandler) 181 | subscribe(acceleration, "acceleration.active", eventHandler) 182 | subscribe(motion, "motion.active", eventHandler) 183 | subscribe(mySwitch, "switch.on", eventHandler) 184 | subscribe(mySwitchOff, "switch.off", eventHandler) 185 | subscribe(arrivalPresence, "presence.present", eventHandler) 186 | subscribe(departurePresence, "presence.not present", eventHandler) 187 | subscribe(smoke, "smoke.detected", eventHandler) 188 | subscribe(smoke, "smoke.tested", eventHandler) 189 | subscribe(smoke, "carbonMonoxide.detected", eventHandler) 190 | subscribe(water, "water.wet", eventHandler) 191 | subscribe(button1, "button.pushed", eventHandler) 192 | 193 | if (triggerModes) { 194 | subscribe(location,modeChangeHandler) 195 | } 196 | 197 | if (timeOfDay) { 198 | schedule(timeOfDay, scheduledTimeHandler) 199 | } 200 | 201 | if (song) { 202 | saveSelectedSong() 203 | } 204 | } 205 | 206 | def eventHandler(evt) { 207 | if (allOk) { 208 | log.trace "allOk" 209 | def lastTime = state[frequencyKey(evt)] 210 | if (oncePerDayOk(lastTime)) { 211 | if (frequency) { 212 | if (lastTime == null || now() - lastTime >= frequency * 60000) { 213 | takeAction(evt) 214 | } 215 | else { 216 | log.debug "Not taking action because $frequency minutes have not elapsed since last action" 217 | } 218 | } 219 | else { 220 | takeAction(evt) 221 | } 222 | } 223 | else { 224 | log.debug "Not taking action because it was already taken today" 225 | } 226 | } 227 | } 228 | 229 | def modeChangeHandler(evt) { 230 | if (evt.value in triggerModes) { 231 | eventHandler(evt) 232 | } 233 | } 234 | 235 | def scheduledTimeHandler() { 236 | eventHandler(null) 237 | } 238 | 239 | def appTouchHandler(evt) { 240 | takeAction(evt) 241 | } 242 | 243 | private takeAction(evt) { 244 | loadText() 245 | 246 | if (song) { 247 | sonos.each { 248 | it.playSoundAndTrack(cleanUri(state.sound.uri, it?.currentModel), state.sound.duration, state.selectedSong, volume) 249 | } 250 | } 251 | else if (resumePlaying){ 252 | sonos.each { 253 | it.playTrackAndResume(cleanUri(state.sound.uri, it?.currentModel), state.sound.duration, volume) 254 | } 255 | } 256 | else if (volume) { 257 | sonos.each { 258 | it.playTrackAtVolume(cleanUri(state.sound.uri, it?.currentModel), volume) 259 | } 260 | } 261 | else { 262 | sonos.each { 263 | it.playTrack(cleanUri(state.sound.uri, it?.currentModel)) 264 | } 265 | } 266 | 267 | if (frequency || oncePerDay) { 268 | state[frequencyKey(evt)] = now() 269 | } 270 | } 271 | 272 | 273 | private songOptions() { 274 | // Make sure current selection is in the set 275 | log.trace "size ${sonos?.size()}" 276 | def options = new LinkedHashSet() 277 | if (state.selectedSong?.station) { 278 | options << state.selectedSong.station 279 | } 280 | else if (state.selectedSong?.description) { 281 | // TODO - Remove eventually? 'description' for backward compatibility 282 | options << state.selectedSong.description 283 | } 284 | 285 | // Query for recent tracks 286 | 287 | def dataMaps 288 | sonos.each { 289 | dataMaps = it.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} 290 | options.addAll(dataMaps.collect{it.station}) 291 | } 292 | log.trace "${options.size()} songs in list" 293 | options.take(30 * (sonos?.size()?:0)) as List 294 | } 295 | 296 | private saveSelectedSong() { 297 | try { 298 | if (song == state.selectedSong?.station){ 299 | log.debug "Selected song $song" 300 | } 301 | else{ 302 | def dataMaps 303 | def data 304 | log.info "Looking for $song" 305 | 306 | sonos.each { 307 | 308 | dataMaps = it.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} 309 | log.info "Searching ${dataMaps.size()} records" 310 | data = dataMaps.find {s -> s.station == song} 311 | log.info "Found ${data?.station?:"None"}" 312 | if (data) { 313 | state.selectedSong = data 314 | log.debug "Selected song = $state.selectedSong" 315 | } 316 | else if (song == state.selectedSong?.station) { 317 | log.debug "Selected song not found" 318 | } 319 | } 320 | } 321 | } 322 | catch (Throwable t) { 323 | log.error t 324 | } 325 | } 326 | 327 | private frequencyKey(evt) { 328 | "lastActionTimeStamp" 329 | } 330 | 331 | private dayString(Date date) { 332 | def df = new java.text.SimpleDateFormat("yyyy-MM-dd") 333 | if (location.timeZone) { 334 | df.setTimeZone(location.timeZone) 335 | } 336 | else { 337 | df.setTimeZone(TimeZone.getTimeZone("America/New_York")) 338 | } 339 | df.format(date) 340 | } 341 | 342 | private oncePerDayOk(Long lastTime) { 343 | def result = true 344 | if (oncePerDay) { 345 | result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true 346 | log.trace "oncePerDayOk = $result" 347 | } 348 | result 349 | } 350 | 351 | // TODO - centralize somehow 352 | private getAllOk() { 353 | modeOk && daysOk && timeOk 354 | } 355 | 356 | private getModeOk() { 357 | def result = !modes || modes.contains(location.mode) 358 | log.trace "modeOk = $result" 359 | result 360 | } 361 | 362 | private getDaysOk() { 363 | def result = true 364 | if (days) { 365 | def df = new java.text.SimpleDateFormat("EEEE") 366 | if (location.timeZone) { 367 | df.setTimeZone(location.timeZone) 368 | } 369 | else { 370 | df.setTimeZone(TimeZone.getTimeZone("America/New_York")) 371 | } 372 | def day = df.format(new Date()) 373 | result = days.contains(day) 374 | } 375 | log.trace "daysOk = $result" 376 | result 377 | } 378 | 379 | private getTimeOk() { 380 | def result = true 381 | if (starting && ending) { 382 | def currTime = now() 383 | def start = timeToday(starting, location?.timeZone).time 384 | def stop = timeToday(ending, location?.timeZone).time 385 | result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start 386 | } 387 | log.trace "timeOk = $result" 388 | result 389 | } 390 | 391 | private hhmm(time, fmt = "h:mm a") 392 | { 393 | def t = timeToday(time, location.timeZone) 394 | def f = new java.text.SimpleDateFormat(fmt) 395 | f.setTimeZone(location.timeZone ?: timeZone(time)) 396 | f.format(t) 397 | } 398 | 399 | private getTimeLabel() 400 | { 401 | (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" 402 | } 403 | // TODO - End Centralize 404 | 405 | private loadText() { 406 | if (location.timeZone || zipCode) { 407 | def weather = getWeatherFeature("forecast", zipCode) 408 | def current = getWeatherFeature("conditions", zipCode) 409 | def isMetric = location.temperatureScale == "C" 410 | def delim = "" 411 | def sb = new StringBuilder() 412 | list(forecastOptions).sort().each {opt -> 413 | if (opt == "0") { 414 | if (isMetric) { 415 | sb << "The current temperature is ${Math.round(current?.current_observation?.temp_c?:0)} degrees." 416 | } 417 | else { 418 | sb << "The current temperature is ${Math.round(current?.current_observation?.temp_f?:0)} degrees." 419 | } 420 | delim = " " 421 | } 422 | else if (opt == "1" && weather.forecast) { 423 | sb << delim 424 | sb << "Today's forecast is " 425 | if (isMetric) { 426 | sb << weather.forecast.txt_forecast.forecastday[0].fcttext_metric 427 | } 428 | else { 429 | sb << weather.forecast.txt_forecast.forecastday[0].fcttext 430 | } 431 | } 432 | else if (opt == "2" && weather.forecast) { 433 | sb << delim 434 | sb << "Tonight will be " 435 | if (isMetric) { 436 | sb << weather.forecast.txt_forecast.forecastday[1].fcttext_metric 437 | } 438 | else { 439 | sb << weather.forecast.txt_forecast.forecastday[1].fcttext 440 | } 441 | } 442 | else if (opt == "3" && weather.forecast) { 443 | sb << delim 444 | sb << "Tomorrow will be " 445 | if (isMetric) { 446 | sb << weather.forecast.txt_forecast.forecastday[2].fcttext_metric 447 | } 448 | else { 449 | sb << weather.forecast.txt_forecast.forecastday[2].fcttext 450 | } 451 | } 452 | } 453 | 454 | def msg = sb.toString() 455 | msg = msg.replaceAll(/([0-9]+)C/,'$1 degrees') // TODO - remove after next release 456 | state.sound = safeTextToSpeech(normalize(msg)) 457 | } 458 | else { 459 | state.sound = safeTextToSpeech("Please set the location of your hub with the SmartThings mobile app, or enter a zip code to receive weather forecasts.") 460 | } 461 | log.trace "state.sound ${state.sound}" 462 | } 463 | 464 | private list(String s) { 465 | [s] 466 | } 467 | private list(l) { 468 | l 469 | } 470 | 471 | private textToSpeechT(message){ 472 | if (message) { 473 | if (ttsApiKey){ 474 | [uri: "x-rincon-mp3radio://api.voicerss.org/" + "?key=$ttsApiKey&hl=en-us&r=0&f=48khz_16bit_mono&src=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-" , duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 475 | }else{ 476 | message = message.length() >100 ? message[0..90] :message 477 | [uri: "x-rincon-mp3radio://www.translate.google.com/translate_tts?tl=en&client=t&q=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-", duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 478 | } 479 | }else{ 480 | [uri: "https://s3.amazonaws.com/smartapp-media/tts/633e22db83b7469c960ff1de955295f57915bd9a.mp3", duration: "10"] 481 | } 482 | } 483 | 484 | private safeTextToSpeech(message, attempt=0) { 485 | message = message?:"You selected the Text to Speach Function but did not enter a Message" 486 | 487 | try { 488 | textToSpeech(message) 489 | } 490 | catch (Throwable t) { 491 | log.error t 492 | textToSpeechT(message) 493 | } 494 | } 495 | 496 | private normalize(message){ 497 | log.trace "normalize" 498 | def map = ["mph":" Miles per hour", " N " : " North ","NbE" : "North by east","NNE" : "North-northeast","NEbN" : "Northeast by north"," NE " : " Northeast ","NEbE" : "Northeast by east","ENE" : "East-northeast","EbN" : "East by north"," E " : " East ","EbS" : "East by south","ESE" : "East-southeast","SEbE" : "Southeast by east"," SE " : " Southeast ","SEbS" : "Southeast by south","SSE" : "South-southeast","SbE" : "South by east"," S " : " South ","SbW" : "South by west","SSW" : "South-southwest","SWbS" : "Southwest by south"," SW " : " Southwest ","SWbW" : "Southwest by west","WSW" : "West-southwest","WbS" : "West by south"," W " : " West ","WbN" : "West by north","WNW" : "West-northwest","NWbW" : "Northwest by west"," NW " : " Northwest ","NWbN" : "Northwest by north","NNW" : "North-northwest","NbW" : "North by west"] 499 | if (message){ 500 | map.each{ k, v -> message = message.replaceAll("(?i)"+k,v) } 501 | message = message.replaceAll(/\d+[CF]/, { f -> f.replaceAll(/[CF]/,f.contains("F") ? " Fahrenheit" :" Celsius") } ) 502 | } 503 | message 504 | } 505 | 506 | private cleanUri(uri,model="") { 507 | log.trace "cleanUri($uri,$model)" 508 | if (uri){ 509 | uri = uri.replace("https:","http:") 510 | if (model?.toLowerCase()?.contains("sonos")){ 511 | uri = uri.replace("http:","x-rincon-mp3radio:") 512 | }else{ 513 | uri = uri.replace("x-rincon-mp3radio:","http:") 514 | } 515 | } 516 | log.trace " uri: $uri" 517 | return uri 518 | } 519 | -------------------------------------------------------------------------------- /WatchDog.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2015 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 | * WatchDog 14 | * 15 | * Author: Ule 16 | * Date: 2015-11-03 17 | */ 18 | definition( 19 | name: "WatchDog", 20 | namespace: "mujica", 21 | author: "Ule", 22 | description: "Poll, Refresh and verify if timer works each time an event occurs.", 23 | category: "SmartThings Labs", 24 | iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/Talkingclock.png", 25 | iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/Talkingclock@2x.png", 26 | iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/Talkingclock@2x.png" 27 | ) 28 | 29 | preferences { 30 | page(name: "mainPage", title: "Poll, Refresh and verify if timer works each time an event occurs.", install: true, uninstall: true) 31 | } 32 | 33 | def mainPage() { 34 | dynamicPage(name: "mainPage") { 35 | def anythingSet = anythingSet() 36 | 37 | section("Devices") { 38 | input "poll", "capability.polling", title:"Select devices to be polled", multiple:true, required:false 39 | input "refresh", "capability.refresh", title:"Select devices to be refreshed", multiple:true, required:false 40 | input "interval", "number", title:"Set minutes", defaultValue:5 41 | } 42 | 43 | if (anythingSet) { 44 | section("Verify Timer When"){ 45 | ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 46 | ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 47 | ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 48 | ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 49 | ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 50 | ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 51 | ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 52 | ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 53 | ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 54 | ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 55 | ifSet "temperature", "capability.temperatureMeasurement", title: "Temperature", required: false, multiple: true 56 | ifSet "powerMeter", "capability.powerMeter", title: "Power Meter", required: false, multiple: true 57 | ifSet "energyMeter", "capability.energyMeter", title: "Energy", required: false, multiple: true 58 | ifSet "signalStrength", "capability.signalStrength", title: "Signal Strength", required: false, multiple: true 59 | ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 60 | ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true 61 | } 62 | } 63 | def hideable = anythingSet || app.installationState == "COMPLETE" 64 | def sectionTitle = anythingSet ? "Select additional triggers" : "Verify Timer When..." 65 | 66 | section(sectionTitle, hideable: hideable, hidden: true){ 67 | ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 68 | ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 69 | ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 70 | ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 71 | ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 72 | ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 73 | ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 74 | ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 75 | ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 76 | ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 77 | ifUnset "temperature", "capability.temperatureMeasurement", title: "Temperature", required: false, multiple: true 78 | ifUnset "signalStrength", "capability.signalStrength", title: "Signal Strength", required: false, multiple: true 79 | ifUnset "powerMeter", "capability.powerMeter", title: "Power Meter", required: false, multiple: true 80 | ifUnset "energyMeter", "capability.energyMeter", title: "Energy Meter", required: false, multiple: true 81 | ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 82 | ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true 83 | } 84 | } 85 | } 86 | private anythingSet() { 87 | for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water", "temperature","signalStrength","powerMeter","energyMeter","button1","timeOfDay","triggerModes","timeOfDay"]) { 88 | if (settings[name]) { 89 | return true 90 | } 91 | } 92 | return false 93 | } 94 | 95 | private ifUnset(Map options, String name, String capability) { 96 | if (!settings[name]) { 97 | input(options, name, capability) 98 | } 99 | } 100 | 101 | private ifSet(Map options, String name, String capability) { 102 | if (settings[name]) { 103 | input(options, name, capability) 104 | } 105 | } 106 | 107 | def installed() { 108 | log.debug "Installed" 109 | } 110 | 111 | def updated() { 112 | log.debug "Updated with settings: ${settings}" 113 | unsubscribe() 114 | subscribeToEvents() 115 | unschedule() 116 | scheduleActions() 117 | scheduledActionsHandler() 118 | } 119 | 120 | def subscribeToEvents() { 121 | subscribe(app, appTouchHandler) 122 | subscribe(contact, "contact.open", eventHandler) 123 | subscribe(contactClosed, "contact.closed", eventHandler) 124 | subscribe(acceleration, "acceleration.active", eventHandler) 125 | subscribe(motion, "motion.active", eventHandler) 126 | subscribe(mySwitch, "switch.on", eventHandler) 127 | subscribe(mySwitchOff, "switch.off", eventHandler) 128 | subscribe(arrivalPresence, "presence.present", eventHandler) 129 | subscribe(departurePresence, "presence.not present", eventHandler) 130 | subscribe(smoke, "smoke.detected", eventHandler) 131 | subscribe(smoke, "smoke.tested", eventHandler) 132 | subscribe(smoke, "carbonMonoxide.detected", eventHandler) 133 | subscribe(water, "water.wet", eventHandler) 134 | subscribe(temperature, "temperature", eventHandler) 135 | subscribe(powerMeter, "power", eventHandler) 136 | subscribe(energyMeter, "energy", eventHandler) 137 | subscribe(signalStrength, "lqi", eventHandler) 138 | subscribe(signalStrength, "rssi", eventHandler) 139 | subscribe(button1, "button.pushed", eventHandler) 140 | if (triggerModes) { 141 | subscribe(location, modeChangeHandler) 142 | } 143 | } 144 | 145 | def eventHandler(evt) { 146 | takeAction(evt) 147 | } 148 | def modeChangeHandler(evt) { 149 | if (evt.value in triggerModes) { 150 | eventHandler(evt) 151 | } 152 | } 153 | 154 | private scheduleActions() { 155 | def minutes = Math.max(settings.interval.toInteger(),1) 156 | def cron = "0 0/${minutes} * * * ?" 157 | schedule(cron, scheduledActionsHandler) 158 | } 159 | def scheduledActionsHandler() { 160 | state.actionTime = new Date().time 161 | if (settings.poll) { 162 | log.trace "poll" 163 | settings.poll*.poll() 164 | } 165 | if (settings.refresh) { 166 | log.trace "refresh" 167 | settings.refresh*.refresh() 168 | } 169 | } 170 | 171 | def appTouchHandler(evt) { 172 | takeAction(evt) 173 | } 174 | 175 | private takeAction(evt) { 176 | def eventTime = new Date().time 177 | if (eventTime > ( 60000 + Math.max(settings.interval.toInteger(),5) * 1000 * 60 + state.actionTime?:0)) { 178 | log.trace "force update " 179 | updated() 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Weather_Report.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 Weather Forecast 14 | * 15 | * Author: SmartThings - Ule 16 | * Date: 2016-2-04 17 | */ 18 | import javax.crypto.Mac; 19 | import javax.crypto.spec.SecretKeySpec; 20 | import java.security.InvalidKeyException; 21 | 22 | 23 | definition( 24 | name: "Weather Report", 25 | namespace: "mujica", 26 | author: "SmartThings-ule", 27 | description: "Play a weather report through your speaker when the mode changes or other events occur, multilanguage", 28 | category: "SmartThings Labs", 29 | iconUrl: "http://tts.urbansa.com/icons/weather.png", 30 | iconX2Url: "http://tts.urbansa.com/icons/weather@2x.png" 31 | ) 32 | 33 | preferences { 34 | page(name: "mainPage", title: "Play the weather report on your speaker", install: true, uninstall: true) 35 | page(name: "triggersPlay") 36 | page(name: "chooseTrack", title: "Select a song or station") 37 | page(name: "ttsKey", title: "Add the Text for Speach Key") 38 | page(name: "ttsSettings", title: "Text for Speach Settings") 39 | page(name: "ttsKeyIvona", title: "Add the Ivona Key") 40 | page(name: "moreOptions") 41 | page(name: "timeIntervalInput", title: "Only during a certain time") { 42 | section { 43 | input "starting", "time", title: "Starting", required: false 44 | input "ending", "time", title: "Ending", required: false 45 | } 46 | } 47 | } 48 | 49 | def mainPage() { 50 | dynamicPage(name: "mainPage") { 51 | def languageOptions = ["ca-es":"Catalan","zh-cn":"Chinese (China)","zh-hk":"Chinese (Hong Kong)","zh-tw":"Chinese (Taiwan)","da-dk":"Danish","nl-nl":"Dutch","en-au":"English (Australia)","en-ca":"English (Canada)","en-gb":"English (Great Britain)","en-in":"English (India)","en-us":"English (United States)","fi-fi":"Finnish","fr-ca":"French (Canada)","fr-fr":"French (France)","de-de":"German","it-it":"Italian","ja-jp":"Japanese","ko-kr":"Korean","nb-no":"Norwegian","pl-pl":"Polish","pt-br":"Portuguese (Brazil)","pt-pt":"Portuguese (Portugal)","ru-ru":"Russian","es-mx":"Spanish (Mexico)","es-es":"Spanish (Spain)","sv-se":"Swedish (Sweden)"] 52 | def languageGoogleOptions = ["ca":"Catalan","zh-CN":"Chinese (Simplified)","zh-TW":"Chinese (Traditional)","da":"Danish","nl":"Dutch","en":"English","fi":"Finnish","fr":"French","de":"German","it":"Italian","no":"Norwegian","pl":"Polish","pt-BR":"Portuguese (Brazil)","pt-PT":"Portuguese (Portugal)","ro":"Romanian","ru":"Russian","es":"Spanish","es-419":"Spanish (Latino)","sv":"Swedish","tr":"Turkish"] 53 | section{ 54 | href "triggersPlay", title: "Select report triggers?",required: flase, description: anythingSet()?"Change triggers":"Tap to set" 55 | } 56 | section { 57 | input("forecastOptions", "enum", defaultValue: "0", title: "Weather report options", description: "Select one or more", multiple: true, 58 | options: [ 59 | ["0": "Current Conditions"], 60 | ["1": "Today's Forecast"], 61 | ["2": "Tonight's Forecast"], 62 | ["3": "Tomorrow's Forecast"], 63 | ] 64 | ) 65 | } 66 | section { 67 | input "sonos", "capability.musicPlayer", title: "On this speaker player", required: true,multiple: true 68 | } 69 | section{ 70 | href "ttsSettings", title: "Text for Speach Settings",required:flase, description:ttsMode + " - " + (ttsMode == "SmartThings"? "English":"") + (ttsMode == "Voice RSS"? languageOptions[ttsLanguage]:"") + (ttsMode == "Google"? languageGoogleOptions[ttsGoogleLanguage]:"") + (ttsMode == "Ivona"? voiceIvona:"") 71 | } 72 | section{ 73 | href "moreOptions", title: "More Options",required: flase, description:"Tap to set" 74 | } 75 | section([mobileOnly:true]) { 76 | label title: "Assign a name", required: false 77 | mode title: "Set for specific mode(s)" 78 | } 79 | } 80 | } 81 | 82 | def moreOptions(){ 83 | dynamicPage(name: "moreOptions") { 84 | section("More options") { 85 | input "resumePlaying", "bool", title: "Resume currently playing music after weather report finishes", required: false, defaultValue: true 86 | href "chooseTrack", title: "Or play this music or radio station", description: song ? state.selectedSong?.station : "Tap to set", state: song ? "complete" : "incomplete" 87 | 88 | input "zipCode", "text", title: "Zip Code", required: false 89 | input "volume", "number", title: "Temporarily change volume", description: "0-100%", required: false 90 | input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false 91 | href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" 92 | input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, 93 | options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 94 | if (settings.modes) { 95 | input "modes", "mode", title: "Only when mode is", multiple: true, required: false 96 | } 97 | input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false 98 | } 99 | } 100 | } 101 | 102 | 103 | def triggersPlay(){ 104 | dynamicPage(name: "triggersPlay") { 105 | triggers() 106 | } 107 | } 108 | 109 | 110 | def triggers(){ 111 | def anythingSet = anythingSet() 112 | 113 | if (anythingSet) { 114 | section("Report When"){ 115 | ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 116 | ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 117 | ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 118 | ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 119 | ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 120 | ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 121 | ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 122 | ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 123 | ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 124 | ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 125 | ifSet "lock", "capability.lock", title: "Lock locks", required: false, multiple: true 126 | ifSet "lockLocks", "capability.lock", title: "Lock unlocks", required: false, multiple: true 127 | ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 128 | ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true 129 | ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false 130 | } 131 | } 132 | def sectionTitle = anythingSet ? "Select additional triggers" : "Report When..." 133 | 134 | section(sectionTitle){ 135 | ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 136 | ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 137 | ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true 138 | ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 139 | ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 140 | ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true 141 | ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 142 | ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 143 | ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true 144 | ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true 145 | ifUnset "lock", "capability.lock", title: "Lock locks", required: false, multiple: true 146 | ifUnset "lock", "capability.lock", title: "Lock unlocks", required: false, multiple: true 147 | ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production 148 | ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true 149 | ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false 150 | } 151 | } 152 | 153 | def chooseTrack() { 154 | dynamicPage(name: "chooseTrack") { 155 | section{ 156 | input "song","enum",title:"Play this track", required:true, multiple: false, options: songOptions() 157 | } 158 | } 159 | } 160 | 161 | def ttsSettings() { 162 | dynamicPage(name: "ttsSettings") { 163 | def languageOptions = ["ca-es":"Catalan","zh-cn":"Chinese (China)","zh-hk":"Chinese (Hong Kong)","zh-tw":"Chinese (Taiwan)","da-dk":"Danish","nl-nl":"Dutch","en-au":"English (Australia)","en-ca":"English (Canada)","en-gb":"English (Great Britain)","en-in":"English (India)","en-us":"English (United States)","fi-fi":"Finnish","fr-ca":"French (Canada)","fr-fr":"French (France)","de-de":"German","it-it":"Italian","ja-jp":"Japanese","ko-kr":"Korean","nb-no":"Norwegian","pl-pl":"Polish","pt-br":"Portuguese (Brazil)","pt-pt":"Portuguese (Portugal)","ru-ru":"Russian","es-mx":"Spanish (Mexico)","es-es":"Spanish (Spain)","sv-se":"Swedish (Sweden)"] 164 | def languageGoogleOptions = ["ca":"Catalan","zh-CN":"Chinese (Simplified)","zh-TW":"Chinese (Traditional)","da":"Danish","nl":"Dutch","en":"English","fi":"Finnish","fr":"French","de":"German","it":"Italian","no":"Norwegian","pl":"Polish","pt-BR":"Portuguese (Brazil)","pt-PT":"Portuguese (Portugal)","ro":"Romanian","ru":"Russian","es":"Spanish","es-419":"Spanish (Latino)","sv":"Swedish","tr":"Turkish"] 165 | section() { 166 | //input "externalTTS", "bool", title: "Force Only External Text to Speech", required: false, defaultValue: false 167 | 168 | input "ttsMode", "enum", title: "Mode?", required: true, defaultValue: "SmartThings",submitOnChange:true, options: ["SmartThings","Ivona","Voice RSS","Google"] 169 | input "ttsGoogleLanguage","enum",title:"Google Language", required:true, multiple: false, defaultValue: "en", options: languageGoogleOptions 170 | href "ttsKey", title: "Voice RSS Key", description: ttsApiKey, state: ttsApiKey ? "complete" : "incomplete", required: ttsMode == "Voice RSS"?true:false 171 | input "ttsLanguage","enum",title:"Voice RSS Language", required:true, multiple: false, defaultValue: "en-us", options: languageOptions 172 | href "ttsKeyIvona", title: "Ivona Access Key", description: "${ttsAccessKey?:""}-${ttsSecretKey?:""}" ,state: ttsAccessKey && ttsSecretKey ? "complete" : "incomplete", required: ttsMode == "Ivona" ? true:false 173 | input "voiceIvona", "enum", title: "Ivona Voice?", required: true, defaultValue: "en-US Salli", options: ["cy-GB Gwyneth","cy-GB Geraint","da-DK Naja","da-DK Mads","de-DE Marlene","de-DE Hans","en-US Salli","en-US Joey","en-AU Nicole","en-AU Russell","en-GB Amy","en-GB Brian","en-GB Emma","en-GB Gwyneth","en-GB Geraint","en-IN Raveena","en-US Chipmunk","en-US Eric","en-US Ivy","en-US Jennifer","en-US Justin","en-US Kendra","en-US Kimberly","es-ES Conchita","es-ES Enrique","es-US Penelope","es-US Miguel","fr-CA Chantal","fr-FR Celine","fr-FR Mathieu","is-IS Dora","is-IS Karl","it-IT Carla","it-IT Giorgio","nb-NO Liv","nl-NL Lotte","nl-NL Ruben","pl-PL Agnieszka","pl-PL Jacek","pl-PL Ewa","pl-PL Jan","pl-PL Maja","pt-BR Vitoria","pt-BR Ricardo","pt-PT Cristiano","pt-PT Ines","ro-RO Carmen","ru-RU Tatyana","ru-RU Maxim","sv-SE Astrid","tr-TR Filiz"] 174 | } 175 | section("Google do not requiere API KEY but is limited to 200 Chars and could be blocked any time.") {} 176 | } 177 | } 178 | 179 | def ttsKey() { 180 | dynamicPage(name: "ttsKey") { 181 | section{ 182 | input "ttsApiKey", "text", title: "TTS Key", required: false, defaultValue:"" 183 | } 184 | section ("Voice RSS provides free Text-to-Speech API as WEB service, allows 350 free request per day with high quality voice") { 185 | href(name: "hrefRegister", 186 | title: "Register", 187 | required: false, 188 | style: "external", 189 | url: "http://www.voicerss.org/registration.aspx", 190 | description: "Register and obtain you TTS Key") 191 | href(name: "hrefKnown", 192 | title: "Known about Voice RSS", 193 | required: false, 194 | style: "external", 195 | url: "http://www.voicerss.org/", 196 | description: "Go to www.voicerss.org") 197 | } 198 | } 199 | } 200 | 201 | def ttsKeyIvona() { 202 | dynamicPage(name: "ttsKeyIvona") { 203 | section{ 204 | input "ttsAccessKey", "text", title: "Ivona Access Key", required: false, defaultValue:"" 205 | input "ttsSecretKey", "text", title: "Ivona Secret Key", required: false, defaultValue:"" 206 | } 207 | section ("Ivona provides free Text-to-Speech API as WEB service, allows 50K free request per month with high quality voice") { 208 | href(name: "hrefRegisterIvona", 209 | title: "Register", 210 | required: false, 211 | style: "external", 212 | url: "https://www.ivona.com/us/for-business/speech-cloud/", 213 | description: "Register and obtain you Access and Secret Key") 214 | href(name: "hrefKnownIvona", 215 | title: "Known about Ivona", 216 | required: false, 217 | style: "external", 218 | url: "https://www.ivona.com/us/", 219 | description: "Go to www.ivona.com") 220 | } 221 | } 222 | } 223 | 224 | private anythingSet() { 225 | for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","timeOfDay","triggerModes"]) { 226 | if (settings[name]) { 227 | return true 228 | } 229 | } 230 | return false 231 | } 232 | 233 | private ifUnset(Map options, String name, String capability) { 234 | if (!settings[name]) { 235 | input(options, name, capability) 236 | } 237 | } 238 | 239 | private ifSet(Map options, String name, String capability) { 240 | if (settings[name]) { 241 | input(options, name, capability) 242 | } 243 | } 244 | 245 | def installed() { 246 | log.debug "Installed with settings: ${settings}" 247 | subscribeToEvents() 248 | } 249 | 250 | def updated() { 251 | log.debug "Updated with settings: ${settings}" 252 | unsubscribe() 253 | unschedule() 254 | subscribeToEvents() 255 | } 256 | 257 | def subscribeToEvents() { 258 | subscribe(app, appTouchHandler) 259 | subscribe(contact, "contact.open", eventHandler) 260 | subscribe(contactClosed, "contact.closed", eventHandler) 261 | subscribe(acceleration, "acceleration.active", eventHandler) 262 | subscribe(motion, "motion.active", eventHandler) 263 | subscribe(mySwitch, "switch.on", eventHandler) 264 | subscribe(mySwitchOff, "switch.off", eventHandler) 265 | subscribe(arrivalPresence, "presence.present", eventHandler) 266 | subscribe(departurePresence, "presence.not present", eventHandler) 267 | subscribe(smoke, "smoke.detected", eventHandler) 268 | subscribe(smoke, "smoke.tested", eventHandler) 269 | subscribe(smoke, "carbonMonoxide.detected", eventHandler) 270 | subscribe(water, "water.wet", eventHandler) 271 | subscribe(button1, "button.pushed", eventHandler) 272 | 273 | if (triggerModes) { 274 | subscribe(location,modeChangeHandler) 275 | } 276 | 277 | if (timeOfDay) { 278 | schedule(timeOfDay, scheduledTimeHandler) 279 | } 280 | 281 | if (song) { 282 | saveSelectedSong() 283 | } 284 | } 285 | 286 | def eventHandler(evt) { 287 | if (allOk) { 288 | log.trace "allOk" 289 | def lastTime = state[frequencyKey(evt)] 290 | if (oncePerDayOk(lastTime)) { 291 | if (frequency) { 292 | if (lastTime == null || now() - lastTime >= frequency * 60000) { 293 | takeAction(evt) 294 | } 295 | else { 296 | log.debug "Not taking action because $frequency minutes have not elapsed since last action" 297 | } 298 | } 299 | else { 300 | takeAction(evt) 301 | } 302 | } 303 | else { 304 | log.debug "Not taking action because it was already taken today" 305 | } 306 | } 307 | } 308 | 309 | def modeChangeHandler(evt) { 310 | if (evt.value in triggerModes) { 311 | eventHandler(evt) 312 | } 313 | } 314 | 315 | def scheduledTimeHandler() { 316 | eventHandler(null) 317 | } 318 | 319 | def appTouchHandler(evt) { 320 | takeAction(evt) 321 | } 322 | 323 | private takeAction(evt) { 324 | loadText() 325 | if (song) { 326 | sonos.each { 327 | it.playSoundAndTrack(cleanUri(state.sound.uri, it?.currentModel), state.sound.duration, state.selectedSong, volume) 328 | } 329 | } 330 | else if (resumePlaying){ 331 | sonos.each { 332 | it.playTrackAndResume(cleanUri(state.sound.uri, it?.currentModel), state.sound.duration, volume) 333 | } 334 | } 335 | else if (volume) { 336 | sonos.each { 337 | it.playTrackAtVolume(cleanUri(state.sound.uri, it?.currentModel), volume) 338 | } 339 | } 340 | else { 341 | sonos.each { 342 | it.playTrack(cleanUri(state.sound.uri, it?.currentModel)) 343 | } 344 | } 345 | if (frequency || oncePerDay) { 346 | state[frequencyKey(evt)] = now() 347 | } 348 | } 349 | 350 | 351 | private songOptions() { 352 | // Make sure current selection is in the set 353 | log.trace "size ${sonos?.size()}" 354 | def options = new LinkedHashSet() 355 | if (state.selectedSong?.station) { 356 | options << state.selectedSong.station 357 | } 358 | else if (state.selectedSong?.description) { 359 | // TODO - Remove eventually? 'description' for backward compatibility 360 | options << state.selectedSong.description 361 | } 362 | 363 | // Query for recent tracks 364 | 365 | def dataMaps 366 | sonos.each { 367 | dataMaps = it.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} 368 | options.addAll(dataMaps.collect{it.station}) 369 | } 370 | log.trace "${options.size()} songs in list" 371 | options.take(30 * (sonos?.size()?:0)) as List 372 | } 373 | 374 | private saveSelectedSong() { 375 | try { 376 | if (song == state.selectedSong?.station){ 377 | log.debug "Selected song $song" 378 | } 379 | else{ 380 | def dataMaps 381 | def data 382 | log.info "Looking for $song" 383 | 384 | sonos.each { 385 | 386 | dataMaps = it.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} 387 | log.info "Searching ${dataMaps.size()} records" 388 | data = dataMaps.find {s -> s.station == song} 389 | log.info "Found ${data?.station?:"None"}" 390 | if (data) { 391 | state.selectedSong = data 392 | log.debug "Selected song = $state.selectedSong" 393 | } 394 | else if (song == state.selectedSong?.station) { 395 | log.debug "Selected song not found" 396 | } 397 | } 398 | } 399 | } 400 | catch (Throwable t) { 401 | log.error t 402 | } 403 | } 404 | 405 | private frequencyKey(evt) { 406 | "lastActionTimeStamp" 407 | } 408 | 409 | private dayString(Date date) { 410 | def df = new java.text.SimpleDateFormat("yyyy-MM-dd") 411 | if (location.timeZone) { 412 | df.setTimeZone(location.timeZone) 413 | } 414 | else { 415 | df.setTimeZone(TimeZone.getTimeZone("America/New_York")) 416 | } 417 | df.format(date) 418 | } 419 | 420 | private oncePerDayOk(Long lastTime) { 421 | def result = true 422 | if (oncePerDay) { 423 | result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true 424 | log.trace "oncePerDayOk = $result" 425 | } 426 | result 427 | } 428 | 429 | // TODO - centralize somehow 430 | private getAllOk() { 431 | modeOk && daysOk && timeOk 432 | } 433 | 434 | private getModeOk() { 435 | def result = !modes || modes.contains(location.mode) 436 | log.trace "modeOk = $result" 437 | result 438 | } 439 | 440 | private getDaysOk() { 441 | def result = true 442 | if (days) { 443 | def df = new java.text.SimpleDateFormat("EEEE") 444 | if (location.timeZone) { 445 | df.setTimeZone(location.timeZone) 446 | } 447 | else { 448 | df.setTimeZone(TimeZone.getTimeZone("America/New_York")) 449 | } 450 | def day = df.format(new Date()) 451 | result = days.contains(day) 452 | } 453 | log.trace "daysOk = $result" 454 | result 455 | } 456 | 457 | private getTimeOk() { 458 | def result = true 459 | if (starting && ending) { 460 | def currTime = now() 461 | def start = timeToday(starting, location?.timeZone).time 462 | def stop = timeToday(ending, location?.timeZone).time 463 | result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start 464 | } 465 | log.trace "timeOk = $result" 466 | result 467 | } 468 | 469 | private hhmm(time, fmt = "h:mm a") 470 | { 471 | def t = timeToday(time, location.timeZone) 472 | def f = new java.text.SimpleDateFormat(fmt) 473 | f.setTimeZone(location.timeZone ?: timeZone(time)) 474 | f.format(t) 475 | } 476 | 477 | private getTimeLabel() 478 | { 479 | (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" 480 | } 481 | // TODO - End Centralize 482 | 483 | private loadText() { 484 | if (location.timeZone || zipCode) { 485 | def weather 486 | def current 487 | def isMetric = location.temperatureScale == "C" 488 | def delim = ". " 489 | def sb = new StringBuilder() 490 | def languages = ["ca-es":"CA","zh-cn":"CN","zh-hk":"CN","zh-tw":"TW","da-dk":"DK","nl-nl":"NL","en-au":"EN","en-ca":"EN","en-gb":"LI","en-in":"EN","en-us":"EN","fi-fi":"FI","fr-ca":"FC","fr-fr":"FR","de-de":"DL","it-it":"IT","ja-jp":"JP","ko-kr":"KR","nb-no":"NO","pl-pl":"PL","pt-br":"BR","pt-pt":"BR","ru-ru":"RU","es-mx":"SP","es-es":"SP","sv-se":"SW","cy-GB Gwyneth":"LI","cy-GB Geraint":"LI","da-DK Naja":"DK","da-DK Mads":"DK","de-DE Marlene":"DL","de-DE Hans":"DL","en-US Salli":"EN","en-US Joey":"EN","en-AU Nicole":"EN","en-AU Russell":"EN","en-GB Amy":"LI","en-GB Brian":"LI","en-GB Emma":"LI","en-GB Gwyneth":"LI","en-GB Geraint":"LI","en-IN Raveena":"EN","en-US Chipmunk":"EN","en-US Eric":"EN","en-US Ivy":"En","en-US Jennifer":"EN","en-US Justin":"EN","en-US Kendra":"EN","en-US Kimberly":"EN","es-ES Conchita":"SP","es-ES Enrique":"SP","es-US Penelope":"SP","es-US Miguel":"SP","fr-CA Chantal":"FC","fr-FR Celine":"FR","fr-FR Mathieu":"FR","is-IS Dora":"IS","is-IS Karl":"IS","it-IT Carla":"IT","it-IT Giorgio":"IT","nb-NO Liv":"NO","nl-NL Lotte":"NL","nl-NL Ruben":"NL","pl-PL Agnieszka":"PL","pl-PL Jacek":"PL","pl-PL Ewa":"PL","pl-PL Jan":"PL","pl-PL Maja":"PL","pt-BR Vitoria":"BR","pt-BR Ricardo":"BR","pt-PT Cristiano":"BR","pt-PT Ines":"BR","ro-RO Carmen":"RO","ru-RU Tatyana":"RU","ru-RU Maxim":"RU","sv-SE Astrid":"SW","tr-TR Filiz":"TR","ca":"CA","zh-CN":"CN","zh-TW":"CN","da":"DK","nl":"NL","en":"EN","fi":"FI","fr":"FR","de":"DL","it":"IT","no":"NO","pl":"PL","pt-BR":"BR","pt-PT":"BR","ro":"RO","ru":"RU","es":"SP","es-419":"SP","sv":"SW","tr":"TR"] 491 | state.language ="EN" 492 | 493 | def titles = [ 494 | "CA": ["La temperatura actual és","graus","Avui dia, la previsió és","aquesta Nit serà","Demà serà"], 495 | "DK": ["Den aktuelle temperatur er","grader","Today' s prognose er","i Aften vil være","i Morgen vil være"], 496 | "NL": ["Het huidige temperatuur is","graden","Vandaag is de prognose is","Tonight","Morgen"], 497 | "LI": ["The current temperature is","degrees","Today's forecast is","Tonight will be","Tomorrow will be"], 498 | "FI": ["Lämpötila","astetta","Tänään on ennuste on","Tänään","Huomenna"], 499 | "FC": ["La température actuelle est","degrés","aujourd'Hui, les prévisions de l'est","ce Soir sera","Demain, ce sera"], 500 | "FR": ["La température actuelle est","degrés","aujourd'Hui, les prévisions de l'est","ce Soir sera","Demain, ce sera"], 501 | "DL": ["Aktuelle Temperatur","Grad","die heutige Prognose ist","Heute","Morgen"], 502 | "IT": ["La temperatura attuale è","gradi","Oggi previsioni","Stasera sarà","Domani sarà"], 503 | "NO": ["Dagens temperatur er","grader","Dagens prognose er","i Kveld vil være","i Morgen vil være"], 504 | "PL": ["Aktualna temperatura","stopień","prognoza pogody","dziś","jutro będzie"], 505 | "RU": ["Текущая температура","градусы","прогноз","сегодня будет","завтра будет"], 506 | "SP": ["La Temperatura actual es de","grados","Se pronostica para hoy","Para esta Noche","Mañana "], 507 | "SW": ["Den nuvarande temperaturen är","grader","Dagens prognos är","i Kväll kommer att vara","i Morgon kommer att vara"], 508 | "IS": ["Núverandi hitinn er","gráður","í Dag er spá er","í Kvöld verður það","á Morgun verður"], 509 | "RO": ["Temperatura curentă","grade","prognoza","azi","mâine va fi"], 510 | "TR": ["Geçerli sıcaklık","derece","Bugün hava","Akşam","Yarın olacak"], 511 | "BR": ["A temperatura atual é","graus","Hoje a previsão é","hoje a Noite vai ser","Amanhã vai ser"], 512 | "EN": ["The current temperature is","degrees","Today's forecast is","Tonight will be","Tomorrow will be"], 513 | "TW": ["","","","",""], 514 | "CN": ["","","","",""] 515 | ] 516 | 517 | switch (ttsMode){ 518 | case "Ivona": 519 | state.language = languages[voiceIvona] 520 | delim = " ... " 521 | break 522 | case "Voice RSS": 523 | state.language = languages[ttsLanguage] 524 | delim = ". " 525 | break 526 | case "Google": 527 | state.language = languages[ttsGoogleLanguage] 528 | delim = " ... " 529 | break 530 | } 531 | ttsGoogleLanguage 532 | def language = state.language ? "/lang:${state.language}":"" 533 | 534 | weather = getWeatherFeature("forecast$language", zipCode) 535 | current = getWeatherFeature("conditions$language", zipCode) 536 | 537 | list(forecastOptions).sort().each {opt -> 538 | if (opt == "0") { 539 | if (isMetric) { 540 | sb << "${titles[state.language][0]} ${Math.round(current?.current_observation?.temp_c?:0)} ${titles[state.language][1]}." 541 | } 542 | else { 543 | sb << "${titles[state.language][0]} ${Math.round(current?.current_observation?.temp_f?:0)} ${titles[state.language][1]}." 544 | } 545 | //delim = " " 546 | } 547 | else if (opt == "1" && weather.forecast) { 548 | sb << delim 549 | sb << "${titles[state.language][2]} " 550 | if (isMetric) { 551 | sb << weather.forecast.txt_forecast.forecastday[0].fcttext_metric 552 | } 553 | else { 554 | sb << weather.forecast.txt_forecast.forecastday[0].fcttext 555 | } 556 | } 557 | else if (opt == "2" && weather.forecast) { 558 | sb << delim 559 | sb << "${titles[state.language][3]} " 560 | if (isMetric) { 561 | sb << weather.forecast.txt_forecast.forecastday[1].fcttext_metric 562 | } 563 | else { 564 | sb << weather.forecast.txt_forecast.forecastday[1].fcttext 565 | } 566 | } 567 | else if (opt == "3" && weather.forecast) { 568 | sb << delim 569 | sb << "${titles[state.language][4]} " 570 | if (isMetric) { 571 | sb << weather.forecast.txt_forecast.forecastday[2].fcttext_metric 572 | } 573 | else { 574 | sb << weather.forecast.txt_forecast.forecastday[2].fcttext 575 | } 576 | } 577 | } 578 | 579 | def msg = sb.toString() 580 | msg = msg.replaceAll(/([0-9]+)C/,'$1 degrees') // TODO - remove after next release 581 | state.sound = safeTextToSpeech(normalize(msg)) 582 | } 583 | else { 584 | state.sound = safeTextToSpeech("Please set the location of your hub with the SmartThings mobile app, or enter a zip code to receive weather forecasts.") 585 | } 586 | log.trace "state.sound ${state.sound}" 587 | } 588 | 589 | private list(String s) { 590 | [s] 591 | } 592 | private list(l) { 593 | l 594 | } 595 | 596 | 597 | private textToSpeechT(message){ 598 | if (message) { 599 | if(ttsAccessKey && ttsSecretKey){ 600 | [uri: ttsIvona(message), duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 601 | } 602 | else if (ttsApiKey){ 603 | [uri: "x-rincon-mp3radio://api.voicerss.org/" + "?key=$ttsApiKey&hl="+ ttsLanguage +"&r=0&f=48khz_16bit_mono&src=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-" , duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 604 | } 605 | else{ 606 | message = message.length() >195 ? message[0..195] :message 607 | [uri: "x-rincon-mp3radio://www.translate.google.com/translate_tts?tl="+ttsGoogleLanguage+"&client=tw-ob&q=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-", duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 608 | } 609 | }else{ 610 | [uri: "https://s3.amazonaws.com/smartapp-media/tts/633e22db83b7469c960ff1de955295f57915bd9a.mp3", duration: "10"] 611 | } 612 | } 613 | 614 | private safeTextToSpeech(message) { 615 | 616 | message = message?:"You selected the Text to Speach Function but did not enter a Message" 617 | switch(ttsMode){ 618 | case "Ivona": 619 | [uri: ttsIvona(message), duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 620 | break 621 | case "Voice RSS": 622 | [uri: "x-rincon-mp3radio://api.voicerss.org/" + "?key=$ttsApiKey&hl="+ ttsLanguage +"&r=0&f=48khz_16bit_mono&src=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-" , duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 623 | break 624 | case "Google": 625 | message = message.length() >195 ? message[0..195] :message 626 | [uri: "x-rincon-mp3radio://www.translate.google.com/translate_tts?tl="+ttsGoogleLanguage+"&client=tw-ob&q=" + URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20') +"&sf=//s3.amazonaws.com/smartapp-", duration: "${5 + Math.max(Math.round(message.length()/12),2)}"] 627 | break 628 | default: 629 | try { 630 | textToSpeech(message) 631 | } 632 | catch (Throwable t) { 633 | log.error t 634 | textToSpeechT(message) 635 | } 636 | break 637 | } 638 | } 639 | 640 | private normalize(message){ 641 | def abbreviations = [ 642 | "CA": ["C\\.":" Centígrads","F\\.":" Fahrenheit"], 643 | "DK": ["ºC\\.":"grader celsius","ºF\\.":"grader Fahrenheit"], 644 | "NL": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 645 | "LI": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 646 | "FI": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 647 | "FC": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 648 | "FR": ["ºC\\.":"degrés","ºF\\.":"degrés","km/h":" Kilomètres par heure","mi/h":" Miles par heure"], 649 | "DL": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 650 | "IT": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 651 | "NO": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 652 | "PL": ["ºC\\.":"Celsjusz","ºF\\.":"Fahrenheita"], 653 | "RU": ["ºС\\.":"Цельсий","ºF\\.":"По Фаренгейту"], 654 | "SP": [" C\\.":" grados"," F\\.":" grados", "km/h":" Kilometros por hora","milla/h":" Millas por hora"," E ":" Este "," N ":" Norte "," S ":" Sur "," W ":" Oeste ", " O ":" Oeste ","NNE":"Nor Nordeste","NE":"Nordeste","ENE":"Este Nordeste","ESE":"Este Sudeste","SE":"Sudeste","SSE":"Sud Sudeste","SSO":"Sud Sudoeste","SSW":"Sud Sudoeste","SO":"Sudoeste","SW":"Sudoeste","OSO":"Oeste Sudoeste","WSW":"Oeste Sudoeste","ONO":"Oesnoroeste","WNW":"Oesnoroeste","NO":"Noroeste","NW":"Noroeste","NNO":"Nornoroeste","NNW":"Nornoroeste"], 655 | "SW": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 656 | "IS": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 657 | "RO": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 658 | "TR": ["°C\\.":" Selsius","°F\\.":" Fahrenhayt"], 659 | "BR": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"," E ":" Este "," N ":" Norte "," S ":" Sul "," W ":" Oeste ", " O ":" Oeste ","NNE":"Nor Nordeste","NE":"Nordeste","ENE":"Lés Nordeste","ESE":"Lés Sudeste","SE":"Sudeste","SSE":"Su Sudeste","SSO":"Su Sudoeste","SSW":"Su Sudoeste","SO":"Sudoeste","SW":"Sudoeste","OSO":"Oés Sudoeste","WSW":"Oés Sudoeste","ONO":"Oés Noroeste","WNW":"Oés Noroeste","NO":"Noroeste","NW":"Noroeste","NNO":"Nor Noroeste","NNW":"Nor Noroeste"], 660 | "EN": ["C\\.":" Celsius","F\\.":" Fahrenheit","km/h":" Kilometers per hour","mph":" Miles per hour"," E ":" East "," N ":" North "," S ":" South "," W ":" West ", " O ":" West " ,"ENE":"East-northeast","ESE":"East-southeast","NE":"Northeast","NNE":"North-northeast","NNW":"North-northwest","NW":"Northwest","SE":"Southeast","SSE":"South-southeast","SSW":"South-southwest","SW":"Southwest","WNW":"West-northwest","WSW":"West-southwest"], 661 | "TW": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"], 662 | "CN": ["ºC\\.":"Celsius","ºF\\.":"Fahrenheit"] 663 | ] 664 | // " N " : " North ","NbE" : "North by east","NNE" : "North-northeast","NEbN" : "Northeast by north"," NE " : " Northeast ","NEbE" : "Northeast by east","ENE" : "East-northeast","EbN" : "East by north"," E " : " East ","EbS" : "East by south","ESE" : "East-southeast","SEbE" : "Southeast by east"," SE " : " Southeast ","SEbS" : "Southeast by south","SSE" : "South-southeast","SbE" : "South by east"," S " : " South ","SbW" : "South by west","SSW" : "South-southwest","SWbS" : "Southwest by south"," SW " : " Southwest ","SWbW" : "Southwest by west","WSW" : "West-southwest","WbS" : "West by south"," W " : " West ","WbN" : "West by north","WNW" : "West-northwest","NWbW" : "Northwest by west"," NW " : " Northwest ","NWbN" : "Northwest by north","NNW" : "North-northwest","NbW" : "North by west"], 665 | 666 | def map = abbreviations[state.language] 667 | 668 | if (message){ 669 | map.each{ k, v -> message = message.replaceAll("(?)"+k,v) } 670 | } 671 | message 672 | } 673 | 674 | private cleanUri(uri,model="") { 675 | if (uri){ 676 | uri = uri.replace("https:","http:") 677 | if (model?.toLowerCase()?.contains("sonos")){ 678 | uri = uri.replace("http:","x-rincon-mp3radio:") 679 | }else{ 680 | uri = uri.replace("x-rincon-mp3radio:","http:") 681 | } 682 | } 683 | return uri 684 | } 685 | 686 | 687 | def ttsIvona(message){ 688 | def regionName = "us-east-1"; 689 | def df = new java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'") 690 | df.setTimeZone(TimeZone.getTimeZone("UTC")) 691 | def amzdate = df.format(new Date()) 692 | def canonicalQueryString = "${URLEncoder.encode(message, "UTF-8").replaceAll(/\+/,'%20')}%3F&Input.Type=text%2Fplain&OutputFormat.Codec=MP3&OutputFormat.SampleRate=22050&Parameters.Rate=medium&Voice.Language=${voiceIvona.getAt(0..4)}&Voice.Name=${voiceIvona.getAt(6..-1)}&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=$ttsAccessKey%2F${amzdate.getAt(0..7)}%2F$regionName%2Ftts%2Faws4_request&X-Amz-Date=$amzdate&X-Amz-SignedHeaders=host"; 693 | "http://tts.freeoda.com/tts.php?${now()}=${URLEncoder.encode("$canonicalQueryString&X-Amz-Signature=${hmac_sha256(hmac_sha256(hmac_sha256(hmac_sha256(hmac_sha256("AWS4$ttsSecretKey".bytes,amzdate.getAt(0..7)),regionName),"tts"),"aws4_request"), "AWS4-HMAC-SHA256\n$amzdate\n${amzdate.getAt(0..7)}/$regionName/tts/aws4_request\n${sha256Hash("GET\n/CreateSpeech\nInput.Data=$canonicalQueryString\nhost:tts.${regionName}.ivonacloud.com\n\nhost\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")}").collect { String.format("%02x", it) }.join('')}")}" 694 | } 695 | 696 | def sha256Hash(text) { 697 | java.security.MessageDigest.getInstance("SHA-256").digest(text.bytes).collect { String.format("%02x", it) }.join('') 698 | } 699 | 700 | def hmac_sha256(byte[] secretKey, String data) { 701 | try { 702 | Mac mac = Mac.getInstance("HmacSHA256") 703 | SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey, "HmacSHA256") 704 | mac.init(secretKeySpec) 705 | byte[] digest = mac.doFinal(data.bytes) 706 | return digest 707 | } 708 | catch (InvalidKeyException e) { 709 | log.error "Invalid key exception while converting to HMac SHA256" 710 | } 711 | } 712 | --------------------------------------------------------------------------------