├── DiskStation ├── DiskstationCamera.groovy ├── DiskstationConnect.groovy ├── OtherSmartApps │ ├── CameraControlExample.groovy │ └── PhotoBurstv2.groovy └── readme.md └── WirelessTags ├── WirelessTagsConnect.groovy ├── WirelessTagsMotion.groovy ├── WirelessTagsWater.groovy └── readme.md /DiskStation/DiskstationCamera.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Diskstation Camera 3 | * 4 | * Copyright 2014 David Swanson 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | metadata { 17 | definition (name: "Diskstation Camera", namespace: "swanny", author: "swanny") { 18 | capability "Image Capture" 19 | capability "Switch" 20 | capability "Motion Sensor" 21 | capability "Refresh" 22 | 23 | attribute "panSupported", "string" 24 | attribute "tiltSupported", "string" 25 | attribute "zoomSupported", "string" 26 | attribute "homeSupported", "string" 27 | attribute "maxPresets", "string" 28 | attribute "numPresets", "string" 29 | attribute "curPreset", "string" 30 | attribute "numPatrols", "string" 31 | attribute "curPatrol", "string" 32 | attribute "refreshState", "string" 33 | attribute "autoTake", "string" 34 | attribute "takeImage", "string" 35 | 36 | command "left" 37 | command "right" 38 | command "up" 39 | command "down" 40 | command "zoomIn" 41 | command "zoomOut" 42 | command "home" 43 | command "presetup" 44 | command "presetdown" 45 | command "presetgo" 46 | command "presetGoName", ["string"] 47 | command "patrolup" 48 | command "patroldown" 49 | command "patrolgo" 50 | command "patrolGoName", ["string"] 51 | command "refresh" 52 | command "autoTakeOff" 53 | command "autoTakeOn" 54 | command "motionActivated" 55 | command "motionDeactivate" 56 | command "initChild" 57 | command "doRefreshWait" 58 | command "doRefreshUpdate" 59 | command "recordEventFailure" 60 | command "putImageInS3" 61 | } 62 | 63 | simulator { 64 | // TODO: define status and reply messages here 65 | } 66 | 67 | tiles { 68 | standardTile("camera", "device.image", width: 1, height: 1, canChangeIcon: false, inactiveLabel: true, canChangeBackground: true) { 69 | state "default", label: "", action: "", icon: "st.camera.dropcam-centered", backgroundColor: "#FFFFFF" 70 | } 71 | 72 | carouselTile("cameraDetails", "device.image", width: 3, height: 2) { } 73 | 74 | standardTile("take", "device.image", width: 1, height: 1, canChangeIcon: false, inactiveLabel: true, canChangeBackground: false) { 75 | state "take", label: "Take", action: "Image Capture.take", icon: "st.camera.dropcam", backgroundColor: "#FFFFFF", nextState:"taking" 76 | state "taking", label:'Taking', action: "", icon: "st.camera.dropcam", backgroundColor: "#53a7c0" 77 | state "image", label: "Take", action: "Image Capture.take", icon: "st.camera.dropcam", backgroundColor: "#FFFFFF", nextState:"taking" 78 | } 79 | 80 | standardTile("up", "device.tiltSupported", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 81 | state "yes", label: "up", action: "up", icon: "st.thermostat.thermostat-up" 82 | state "no", label: "unavail", action: "", icon: "st.thermostat.thermostat-up" 83 | } 84 | 85 | standardTile("down", "device.tiltSupported", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 86 | state "yes", label: "down", action: "down", icon: "st.thermostat.thermostat-down" 87 | state "no", label: "unavail", action: "", icon: "st.thermostat.thermostat-down" 88 | } 89 | 90 | standardTile("left", "device.panSupported", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 91 | state "yes", label: "left", action: "left", icon: "" 92 | state "no", label: "unavail", action: "", icon: "" 93 | } 94 | 95 | standardTile("right", "device.panSupported", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 96 | state "yes", label: "right", action: "right", icon: "" 97 | state "no", label: "unavail", action: "", icon: "" 98 | } 99 | 100 | standardTile("zoomIn", "device.zoomSupported", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 101 | state "yes", label: "zoom in", action: "zoomIn", icon: "st.custom.buttons.add-icon" 102 | state "no", label: "zoom unavail", action: "", icon: "st.custom.buttons.add-icon" 103 | } 104 | 105 | standardTile("zoomOut", "device.zoomSupported", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 106 | state "yes", label: "zoom out", action: "zoomOut", icon: "st.custom.buttons.subtract-icon" 107 | state "no", label: "zoom unavail", action: "", icon: "st.custom.buttons.subtract-icon" 108 | } 109 | 110 | standardTile("home", "device.homeSupported", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false) { 111 | state "yes", label: "home", action: "home", icon: "st.Home.home2" 112 | state "no", label: "unavail", action: "", icon: "st.Home.home2" 113 | } 114 | 115 | standardTile("presetdown", "device.curPreset", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 116 | state "yes", label: "preset", action: "presetdown", icon: "st.thermostat.thermostat-down" 117 | state "0", label: "preset", action: "", icon: "st.thermostat.thermostat-down" 118 | } 119 | 120 | standardTile("presetup", "device.curPreset", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 121 | state "yes", label: "preset", action: "presetup", icon: "st.thermostat.thermostat-up" 122 | state "0", label: "preset", action: "", icon: "st.thermostat.thermostat-up" 123 | } 124 | 125 | standardTile("presetgo", "device.curPreset", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false) { 126 | state "yes", label: '${currentValue}', action: "presetgo", icon: "st.motion.acceleration.inactive" 127 | state "0", label: "N/A", action: "", icon: "st.motion.acceleration.inactive" 128 | } 129 | 130 | standardTile("patroldown", "device.curPatrol", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 131 | state "yes", label: "patrol", action: "patroldown", icon: "st.thermostat.thermostat-down" 132 | state "0", label: "patrol", action: "", icon: "st.thermostat.thermostat-down" 133 | } 134 | 135 | standardTile("patrolup", "device.curPatrol", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false, decoration: "flat") { 136 | state "yes", label: "patrol", action: "patrolup", icon: "st.thermostat.thermostat-up" 137 | state "0", label: "patrol", action: "", icon: "st.thermostat.thermostat-up" 138 | } 139 | 140 | standardTile("patrolgo", "device.curPatrol", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false) { 141 | state "yes", label: '${currentValue}', action: "patrolgo", icon: "st.motion.motion-detector.active" 142 | state "0", label: "N/A", action: "", icon: "st.motion.motion-detector.active" 143 | } 144 | 145 | standardTile("refresh", "device.refreshState", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false) { 146 | state "none", label: "refresh", action: "refresh", icon: "st.secondary.refresh-icon", backgroundColor: "#FFFFFF" 147 | state "want", label: "refresh", action: "refresh", icon: "st.secondary.refresh-icon", backgroundColor: "#53A7C0" 148 | state "waiting", label: "refresh", action: "refresh", icon: "st.secondary.refresh-icon", backgroundColor: "#53A7C0" 149 | } 150 | 151 | standardTile("recordStatus", "device.switch", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false) { 152 | state "off", label: "record", action: "switch.on", icon: "st.camera.camera", backgroundColor: "#FFFFFF" 153 | state "on", label: "stop", action: "switch.off", icon: "st.camera.camera", backgroundColor: "#53A7C0" 154 | } 155 | 156 | standardTile("motion", "device.motion", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false) { 157 | state("active", label:'motion', icon:"st.motion.motion.active", backgroundColor:"#53a7c0") 158 | state("inactive", label:'no motion', icon:"st.motion.motion.inactive", backgroundColor:"#ffffff") 159 | } 160 | 161 | standardTile("auto", "device.autoTake", width: 1, height: 1, canChangeIcon: false, canChangeBackground: false) { 162 | state "off", label: 'No Take', action: "autoTakeOn", icon: "st.motion.motion.active", backgroundColor: "#ffffff" 163 | state "on", label: 'Take', action: "autoTakeOff", icon: "st.motion.motion.active", backgroundColor: "#53a7c0" 164 | } 165 | 166 | main(["camera"]) 167 | details(["cameraDetails", 168 | "take", "motion", "recordStatus", 169 | "presetup", "presetgo", "presetdown", 170 | "patrolup", "patrolgo", "patroldown", 171 | "zoomIn", "up", "zoomOut", 172 | "left", "home", "right", 173 | "refresh", "down", "auto"]) 174 | } 175 | 176 | preferences { 177 | input "takeStream", "number", title: "Stream to capture image from", 178 | description: "Leave blank unless want to use another stream.", defaultValue: "", 179 | required: false, displayDuringSetup: true 180 | } 181 | } 182 | 183 | // parse events into attributes 184 | def parse(String description) { 185 | log.trace "parse called with " + description 186 | } 187 | 188 | def putImageInS3(map) { 189 | def s3ObjectContent 190 | 191 | try { 192 | def imageBytes = getS3Object(map.bucket, map.key + ".jpg") 193 | 194 | if(imageBytes) 195 | { 196 | def picName = getPictureName() 197 | s3ObjectContent = imageBytes.getObjectContent() 198 | def bytes = new ByteArrayInputStream(s3ObjectContent.bytes) 199 | storeImage(picName, bytes) 200 | log.trace "image stored = " + picName 201 | } 202 | 203 | } 204 | catch(Exception e) { 205 | log.error e 206 | } 207 | finally { 208 | //explicitly close the stream 209 | if (s3ObjectContent) { s3ObjectContent.close() } 210 | } 211 | } 212 | 213 | def getCameraID() { 214 | def cameraId = parent.getDSCameraIDbyChild(this) 215 | if (cameraId == null) { 216 | log.trace "could not find device DNI = ${device.deviceNetworkId}" 217 | } 218 | return (cameraId) 219 | } 220 | 221 | // handle commands 222 | def take() { 223 | try { 224 | def lastNum = device.currentState("takeImage")?.integerValue 225 | sendEvent(name: "takeImage", value: "${lastNum+1}") 226 | } 227 | catch(Exception e) { 228 | log.error e 229 | sendEvent(name: "takeImage", value: "0") 230 | } 231 | def hubAction = null 232 | def cameraId = getCameraID() 233 | if ((takeStream != null) && (takeStream != "")){ 234 | log.trace "take picture from camera ${cameraId} stream ${takeStream}" 235 | hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.Camera", "GetSnapshot", "cameraId=${cameraId}&camStm=${takeStream}", 4) 236 | } 237 | else { 238 | log.trace "take picture from camera ${cameraId} default stream" 239 | hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.Camera", "GetSnapshot", "cameraId=${cameraId}", 1) 240 | } 241 | log.debug "take command is: ${hubAction}" 242 | hubAction 243 | } 244 | 245 | def left() { 246 | log.trace "move" 247 | def cameraId = getCameraID() 248 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "Move", "cameraId=${cameraId}&direction=left", 1) 249 | hubAction 250 | } 251 | 252 | def right() { 253 | log.trace "move" 254 | def cameraId = getCameraID() 255 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "Move", "cameraId=${cameraId}&direction=right", 1) 256 | hubAction 257 | } 258 | 259 | def up() { 260 | log.trace "move" 261 | def cameraId = getCameraID() 262 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "Move", "cameraId=${cameraId}&direction=up", 1) 263 | hubAction 264 | } 265 | 266 | def down() { 267 | log.trace "move" 268 | def cameraId = getCameraID() 269 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "Move", "cameraId=${cameraId}&direction=down", 1) 270 | hubAction 271 | } 272 | 273 | def zoomIn() { 274 | log.trace "zoomIn" 275 | def cameraId = getCameraID() 276 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "Zoom", "cameraId=${cameraId}&control=in", 1) 277 | hubAction 278 | } 279 | 280 | def zoomOut() { 281 | log.trace "zoomOut" 282 | def cameraId = getCameraID() 283 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "Zoom", "cameraId=${cameraId}&control=out", 1) 284 | hubAction 285 | } 286 | 287 | def home() { 288 | log.trace "home" 289 | def cameraId = getCameraID() 290 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "Move", "cameraId=${cameraId}&direction=home", 1) 291 | hubAction 292 | } 293 | 294 | def presetup() { 295 | log.trace "ps up" 296 | def maxPresetNum = device.currentState("numPresets")?.integerValue 297 | if (maxPresetNum > 0) { 298 | def presetNum = state.curPresetIndex 299 | presetNum = ((presetNum+1) <= maxPresetNum) ? (presetNum + 1) : 1 300 | state.curPresetIndex = presetNum 301 | sendEvent(name: "curPreset", value: parent.getPresetString(this, presetNum)) 302 | } 303 | } 304 | 305 | def presetdown() { 306 | log.trace "ps down" 307 | def maxPresetNum = device.currentState("numPresets")?.integerValue 308 | if (maxPresetNum > 0) { 309 | def presetNum = state.curPresetIndex 310 | presetNum = ((presetNum-1) > 0) ? (presetNum - 1) : maxPresetNum 311 | state.curPresetIndex = presetNum 312 | sendEvent(name: "curPreset", value: parent.getPresetString(this, presetNum)) 313 | } 314 | } 315 | 316 | def presetgo() { 317 | log.trace "ps go" 318 | def cameraId = getCameraID() 319 | def presetIndex = state.curPresetIndex 320 | def presetNum = parent.getPresetId(this, presetIndex) 321 | if (presetNum != null) { 322 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "GoPreset", "cameraId=${cameraId}&presetId=${presetNum}", 1) 323 | return hubAction 324 | } 325 | } 326 | 327 | def presetGoName(name) { 328 | log.trace "ps go name" 329 | def cameraId = getCameraID() 330 | def presetNum = parent.getPresetIdByString(this, name); 331 | 332 | if (presetNum != null) { 333 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "GoPreset", "cameraId=${cameraId}&presetId=${presetNum}", 1) 334 | return hubAction 335 | } 336 | } 337 | 338 | def patroldown() { 339 | log.trace "pt down" 340 | def patrols = device.currentState("numPatrols")?.integerValue 341 | if (patrols > 0) { 342 | def patrolNum = state.curPatrolIndex 343 | patrolNum = ((patrolNum-1) > 0) ? (patrolNum - 1) : patrols 344 | state.curPatrolIndex = patrolNum 345 | sendEvent(name: "curPatrol", value: parent.getPatrolString(this, patrolNum)) 346 | } 347 | } 348 | 349 | def patrolup() { 350 | log.trace "pt up" 351 | def patrols = device.currentState("numPatrols")?.integerValue 352 | if (patrols > 0) { 353 | def patrolNum = state.curPatrolIndex 354 | patrolNum = ((patrolNum+1) <= patrols) ? (patrolNum + 1) : 1 355 | state.curPatrolIndex = patrolNum 356 | sendEvent(name: "curPatrol", value: parent.getPatrolString(this, patrolNum)) 357 | } 358 | } 359 | 360 | def patrolgo() { 361 | log.trace "pt go" 362 | def cameraId = getCameraID() 363 | def patrolIndex = state.curPatrolIndex 364 | def patrolNum = parent.getPatrolId(this, patrolIndex) 365 | if (patrolNum != null) { 366 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "RunPatrol", "cameraId=${cameraId}&patrolId=${patrolNum}", 2) 367 | return hubAction 368 | } 369 | } 370 | 371 | def patrolGoName(name) { 372 | log.trace "pt go name" 373 | def cameraId = getCameraID() 374 | def patrolNum = parent.getPatrolIdByString(this, name); 375 | 376 | if (patrolNum != null) { 377 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.PTZ", "RunPatrol", "cameraId=${cameraId}&patrolId=${patrolNum}", 2) 378 | return hubAction 379 | } 380 | } 381 | 382 | def refresh() { 383 | log.trace "refresh" 384 | 385 | // if we haven't hit refresh in longer than 10 seconds, we'll just start again 386 | if ((device.currentState("refreshState")?.value == "none") 387 | || (state.refreshTime == null) || ((now() - state.refreshTime) > 30000)) { 388 | log.trace "refresh starting" 389 | sendEvent(name: "refreshState", value: "want") 390 | state.refreshTime = now() 391 | parent.refreshCamera(this) 392 | } 393 | } 394 | 395 | // recording on / off 396 | def on() { 397 | log.trace "start recording" 398 | def cameraId = getCameraID() 399 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.ExternalRecording", "Record", "cameraId=${cameraId}&action=start", 2) 400 | hubAction 401 | } 402 | 403 | def off() { 404 | log.trace "stop recording" 405 | def cameraId = getCameraID() 406 | def hubAction = queueDiskstationCommand_Child("SYNO.SurveillanceStation.ExternalRecording", "Record", "cameraId=${cameraId}&action=stop", 2) 407 | hubAction 408 | } 409 | 410 | def recordEventFailure() { 411 | if (device.currentState("switch")?.value == "on") { 412 | // recording didn't start, turn it off 413 | sendEvent(name: "switch", value: "off") 414 | } 415 | } 416 | 417 | def motionActivated() { 418 | if (device.currentState("motion")?.value != "active") { 419 | sendEvent(name: "motion", value: "active") 420 | } 421 | } 422 | 423 | def motionDeactivate() { 424 | sendEvent(name: "motion", value: "inactive") 425 | } 426 | 427 | def autoTakeOn() { 428 | log.trace "autoon" 429 | sendEvent(name: "autoTake", value: "on") 430 | } 431 | 432 | def autoTakeOff() { 433 | log.trace "autooff" 434 | sendEvent(name: "autoTake", value: "off") 435 | } 436 | 437 | def doRefreshWait() { 438 | sendEvent(name: "refreshState", value: "waiting") 439 | } 440 | 441 | def doRefreshUpdate(capabilities) { 442 | initChild(capabilities) 443 | } 444 | 445 | def initChild(Map capabilities) 446 | { 447 | sendEvent(name: "panSupported", value: (capabilities.ptzPan) ? "yes" : "no") 448 | sendEvent(name: "tiltSupported", value: (capabilities.ptzTilt) ? "yes" : "no") 449 | sendEvent(name: "zoomSupported", value: (capabilities.ptzZoom) ? "yes" : "no") 450 | sendEvent(name: "homeSupported", value: (capabilities.ptzHome) ? "yes" : "no") 451 | 452 | sendEvent(name: "maxPresets", value: capabilities.ptzPresetNumber) 453 | def numPresets = parent.getNumPresets(this).toString() 454 | sendEvent(name: "numPresets", value: numPresets) 455 | def curPreset = (numPresets == "0") ? 0 : 1 456 | state.curPresetIndex = curPreset 457 | sendEvent(name: "curPreset", value: parent.getPresetString(this, curPreset)) 458 | 459 | def numPatrols = parent.getNumPatrols(this).toString() 460 | sendEvent(name: "numPatrols", value: numPatrols) 461 | def curPatrol = (numPatrols == "0") ? 0 : 1 462 | state.curPatrolIndex = curPatrol 463 | sendEvent(name: "curPatrol", value: parent.getPatrolString(this, curPatrol)) 464 | 465 | sendEvent(name: "motion", value: "inactive") 466 | sendEvent(name: "refreshState", value: "none") 467 | if (device.currentState("autoTake")?.value == null) { 468 | sendEvent(name: "autoTake", value: "off") 469 | } 470 | 471 | sendEvent(name: "takeImage", value: "0") 472 | } 473 | 474 | def queueDiskstationCommand_Child(String api, String command, String params, int version) { 475 | def commandData = parent.createCommandData(api, command, params, version) 476 | 477 | log.trace "sending " + commandData.command 478 | 479 | def hubAction = parent.createHubAction(commandData) 480 | hubAction 481 | } 482 | 483 | //helper methods 484 | private getPictureName() { 485 | def pictureUuid = java.util.UUID.randomUUID().toString().replaceAll('-', '') 486 | return device.deviceNetworkId.replaceAll(" ", "_") + "_$pictureUuid" + ".jpg" 487 | } -------------------------------------------------------------------------------- /DiskStation/DiskstationConnect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Diskstation (Connect) 3 | * 4 | * Copyright 2014 David Swanson 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | definition( 18 | name: "Diskstation (Connect)", 19 | namespace: "swanny", 20 | author: "swanny", 21 | description: "Allows you to connect the cameras from the Synology Surveilence Station", 22 | category: "Safety & Security", 23 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 24 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" 25 | ) 26 | 27 | preferences { 28 | page(name:"diskstationDiscovery", title:"Connect with your Diskstation!", content:"diskstationDiscovery") 29 | page(name:"cameraDiscovery", title:"Camera Setup", content:"cameraDiscovery", refreshTimeout:5) 30 | page(name:"motionSetup", title:"Motion Detection Triggers", content:"motionSetup", refreshTimeout:3) 31 | } 32 | 33 | mappings { 34 | path("/DSNotify") { 35 | action: [ 36 | GET: "webNotifyCallback" 37 | ] 38 | } 39 | } 40 | 41 | //PAGES 42 | ///////////////////////////////////// 43 | 44 | def motionSetup() 45 | { 46 | // check for timeout error 47 | state.refreshCountMotion = state.refreshCountMotion+1 48 | if (state.refreshCountMotion > 10) {} 49 | def interval = (state.refreshCountMotion < 4) ? 10 : 5 50 | 51 | if (!state.accessToken) { 52 | createAccessToken() 53 | } 54 | 55 | def url = apiServerUrl("/api/token/${state.accessToken}/smartapps/installations/${app.id}/DSNotify?user=user&password=pass&to=num&msg=Hello+World") 56 | 57 | if (state.motionTested == false) { 58 | return dynamicPage(name:"motionSetup", title:"Motion Detection Triggers", nextPage:"", refreshInterval:interval, install: true, uninstall: true){ 59 | section("Overview") { 60 | paragraph "Motion detected by the cameras can be used as triggers to other ST devices. This step is not required " + 61 | "to use the rest of the features of this SmartApp. Click 'Done' if you don't want to set up motion detection " + 62 | "or continue below to set it up." 63 | } 64 | section("Diskstation Setup") { 65 | paragraph "Follow these steps to set up motion notifications from Surveillance Station. " 66 | paragraph "1. Log into your Diskstation and go to Surveillance Station" 67 | paragraph "2. Choose Notifications from the Menu" 68 | paragraph "3. Go to the SMS tab. Enable SMS notifications. Note that this setup will not actually send " + 69 | "SMS messages but instead overrides the SMS system to call a web link for this SmartApp." 70 | paragraph "4. Click 'Add SMS Service Provider'" 71 | paragraph "5. Copy the text entry field below and past into the SMS URL field" 72 | input "ignore", "text", title:"Web address to copy:", defaultValue:"${url}" 73 | paragraph "6. Name your service provider something like 'Smartthings'" 74 | paragraph "7. Click next" 75 | paragraph "8. In the drop downs, choose 'Username', 'Password', 'Phone Number' and 'Message Content' in order" 76 | paragraph "9. Press Finish" 77 | paragraph "10. Type 'user' for the Username, 'password' in both Password fields" 78 | paragraph "11. Type 123-4567890 for the first phone number" 79 | paragraph "12. Press 'Send a test SMS message' to update this screen" 80 | paragraph "13. Now click on the Settings tab in the Nofications window" 81 | paragraph "14. Go to the Camera section of this pane and then check SMS for Motion Detected" 82 | paragraph "15. With the Motion Detected row highlighted, choose Edit and then Edit Notification from the top left of this pane" 83 | paragraph "16. Put the following text into the Subject line and choose Save" 84 | input "ignore2", "text", title:"", defaultValue:"Camera %CAMERA% on %SS_PKG_NAME% has detected motion" 85 | paragraph "If the page does not say 'success' within 10-15 seconds after sending the test message, " + 86 | "go to the previous page and come back to refresh the screen again. If you still don't have " + 87 | "the success message, retrace these steps." 88 | } 89 | section("Optional Settings", hidden: true, hideable: true) { 90 | input "motionOffDelay", "number", title:"Minutes with no message before motion is deactivated:", defaultValue:1 91 | } 92 | } 93 | } else { 94 | return dynamicPage(name:"motionSetup", title:"Motion Detection Triggers", nextPage:"", install: true, uninstall: true){ 95 | section("Success!") { 96 | paragraph "The test message was received from the DiskStation. " + 97 | "Motion detected by the cameras can now be used as triggers to other ST devices." 98 | } 99 | } 100 | } 101 | } 102 | 103 | 104 | def diskstationDiscovery() 105 | { 106 | log.trace "subscribe to location" 107 | subscribe(location, null, locationHandler, [filterEvents:false]) 108 | state.subscribe = true 109 | 110 | state.commandList = new LinkedList() 111 | 112 | // clear the refresh count for the next page 113 | state.refreshCount = 0 114 | state.motionTested = false 115 | // just default to get new info even though we have some logic later to see if IP has changed, this is more robust 116 | state.getDSinfo = true 117 | 118 | return dynamicPage(name:"diskstationDiscovery", title:"Connect with your Diskstation!", nextPage:"cameraDiscovery", uninstall: true){ 119 | section("Please enter your local network DiskStation information:") { 120 | input "userip", "text", title:"ip address", defaultValue:"192.168.1.99" 121 | input "userport", "text", title:"http port", defaultValue:"5000" 122 | } 123 | section("Please enter your DiskStation login information:") { 124 | input "username", "text", title:"username", defaultValue:"" 125 | input "password", "password", title:"password", defaultValue:"" 126 | } 127 | } 128 | } 129 | 130 | def cameraDiscovery() 131 | { 132 | if(!state.subscribe) { 133 | log.trace "subscribe to location" 134 | subscribe(location, null, locationHandler, [filterEvents:false]) 135 | state.subscribe = true 136 | } 137 | 138 | // see if we need to reprocess the DS info 139 | if ((userip != state.previoususerip) || (userport != state.previoususerport)) { 140 | log.trace "force refresh of DS info" 141 | state.previoususerip = userip 142 | state.previoususerport = userport 143 | state.getDSinfo = true 144 | } 145 | 146 | if ((state.getDSinfo == true) || state.getDSinfo == null) { 147 | getDSInfo() 148 | } 149 | 150 | //if (state.getCameraCapabilities) { 151 | // getCameraCapabilities() 152 | //} 153 | 154 | // check for timeout error 155 | state.refreshCount = state.refreshCount+1 156 | if (state.refreshCount > 20) {state.error = "Network Timeout. Check your ip address and port. You must access a local IP address and a non-https port."} 157 | 158 | state.refreshCountMotion = 0 159 | 160 | def options = camerasDiscovered() ?: [] 161 | def numFound = options.size() ?: 0 162 | 163 | if (state.error == "") 164 | { 165 | if (!state.SSCameraList || (state.commandList.size() > 0)) { 166 | // we're waiting for the list to be created 167 | return dynamicPage(name:"cameraDiscovery", title:"Diskstation", nextPage:"", refreshInterval:4, uninstall: true) { 168 | section("Connecting to ${userip}:${userport}") { 169 | paragraph "This can take a minute. Please wait..." 170 | } 171 | } 172 | } else { 173 | // we have the list now 174 | return dynamicPage(name:"cameraDiscovery", title:"Camera Information", nextPage:"motionSetup", uninstall: true) { 175 | section("See the available cameras:") { 176 | input "selectedCameras", "enum", required:false, title:"Select Cameras (${numFound} found)", multiple:true, options:options 177 | } 178 | section("") { 179 | paragraph "Select the cameras that you want created as ST devices. Cameras will be remembered by the camera name in Surveillance Station. Please do not rename them in Surveillance Station or you may lose access to them in ST. Advanced users may change the ST DNI to the new camera name if needed." 180 | } 181 | } 182 | } 183 | } 184 | else 185 | { 186 | def error = state.error 187 | 188 | // clear the error 189 | state.error = "" 190 | 191 | // force us to reget the DS info 192 | state.previoususerip = "forcereset" 193 | clearDiskstationCommandQueue() 194 | 195 | // show the message 196 | return dynamicPage(name:"cameraDiscovery", title:"Connection Error", nextPage:"", uninstall: true) { 197 | section() { 198 | paragraph error 199 | } 200 | } 201 | } 202 | } 203 | 204 | def getDSInfo() { 205 | // clear camera list for now 206 | state.motionTested = false 207 | state.getDSinfo = false 208 | state.SSCameraList = null 209 | state.error = "" 210 | state.api = ["SYNO.API.Info":[path:"query.cgi",minVersion:1,maxVersion:1]] 211 | state.lastEventTime = null 212 | 213 | clearDiskstationCommandQueue() 214 | 215 | // get APIs 216 | queueDiskstationCommand("SYNO.API.Info", "Query", "query=SYNO.API.Auth", 1) 217 | queueDiskstationCommand("SYNO.API.Info", "Query", "query=SYNO.SurveillanceStation.Camera", 1) 218 | queueDiskstationCommand("SYNO.API.Info", "Query", "query=SYNO.SurveillanceStation.PTZ", 1) 219 | queueDiskstationCommand("SYNO.API.Info", "Query", "query=SYNO.SurveillanceStation.ExternalRecording", 1) 220 | 221 | // login 222 | executeLoginCommand() 223 | 224 | // get cameras 225 | queueDiskstationCommand("SYNO.SurveillanceStation.Camera", "List", "additional=device", 1) 226 | } 227 | 228 | def executeLoginCommand() { 229 | queueDiskstationCommand("SYNO.API.Auth", "Login", "account=${URLEncoder.encode(username, "UTF-8")}&passwd=${URLEncoder.encode(password, "UTF-8")}&session=SurveillanceStation&format=sid", 2) 230 | } 231 | 232 | def getCameraCapabilities() { 233 | state.getCameraCapabilities = false; 234 | state.cameraCapabilities = [:] 235 | state.cameraPresets = [] 236 | state.cameraPatrols = [] 237 | 238 | state.SSCameraList.each { 239 | updateCameraInfo(it) 240 | } 241 | } 242 | 243 | // takes in object from state.SSCameraList 244 | def updateCameraInfo(camera) { 245 | def vendor = camera.additional.device.vendor.replaceAll(" ", "%20") 246 | def model = camera.additional.device.model.replaceAll(" ", "%20") 247 | if ((model == "Define") && (vendor = "User")) { 248 | // user defined camera 249 | def capabilities = [:] 250 | 251 | capabilities.ptzPan = false 252 | capabilities.ptzTilt = false 253 | capabilities.ptzZoom = false 254 | capabilities.ptzHome = false 255 | capabilities.ptzPresetNumber = 0 256 | 257 | state.cameraCapabilities.put(makeCameraModelKey(vendor, model), capabilities) 258 | } else { 259 | // standard camera 260 | //queueDiskstationCommand("SYNO.SurveillanceStation.Camera", "GetCapability", "vendor=${vendor}&model=${model}", 1) 261 | queueDiskstationCommand("SYNO.SurveillanceStation.Camera", "GetCapabilityByCamId", "cameraId=${camera.id}", 4) 262 | 263 | queueDiskstationCommand("SYNO.SurveillanceStation.PTZ", "ListPreset", "cameraId=${camera.id}", 1) 264 | queueDiskstationCommand("SYNO.SurveillanceStation.PTZ", "ListPatrol", "cameraId=${camera.id}", 1) 265 | } 266 | } 267 | 268 | Map camerasDiscovered() { 269 | def map = [:] 270 | state.SSCameraList.each { 271 | map[it.id] = it.name 272 | } 273 | map 274 | } 275 | 276 | def getUniqueCommand(String api, String Command) { 277 | return api + Command 278 | } 279 | 280 | def getUniqueCommand(Map commandData) { 281 | return getUniqueCommand(commandData.api, commandData.command) 282 | } 283 | 284 | def makeCameraModelKey(cameraInfo) { 285 | def vendor = cameraInfo.additional.device.vendor.replaceAll(" ", "%20") 286 | def model = cameraInfo.additional.device.model.replaceAll(" ", "%20") 287 | 288 | return makeCameraModelKey(vendor, model); 289 | } 290 | 291 | def makeCameraModelKey(vendor, model) { 292 | return (vendor + "_" + model) 293 | } 294 | 295 | ///////////////////////////////////// 296 | 297 | def finalizeChildCommand(commandInfo) { 298 | state.lastEventTime = commandInfo.time 299 | } 300 | 301 | def getFirstChildCommand(commandType) { 302 | def commandInfo = null 303 | 304 | // get event type to search for 305 | def searchType = null 306 | switch (commandType) { 307 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetSnapshot"): 308 | searchType = "takeImage" 309 | break 310 | } 311 | 312 | if (searchType != null) { 313 | def children = getChildDevices() 314 | def bestTime = now() 315 | def startTime = now() - 40000 316 | 317 | if (state.lastEventTime != null) { 318 | if (startTime <= state.lastEventTime) { 319 | startTime = state.lastEventTime+1 320 | } 321 | } 322 | 323 | //log.trace "startTime = ${startTime}, now = ${now()}" 324 | 325 | children.each { 326 | // get the events from the child 327 | def events = it.eventsSince(new Date(startTime)) 328 | def typedEvents = events.findAll { it.name == searchType } 329 | 330 | if (typedEvents) { 331 | typedEvents.each { event -> 332 | def eventTime = event.date.getTime() 333 | //log.trace "eventTime = ${eventTime}" 334 | if (eventTime >= startTime && eventTime < bestTime) { 335 | // is it the oldest 336 | commandInfo = [:] 337 | commandInfo.child = it 338 | commandInfo.time = eventTime 339 | bestTime = eventTime 340 | //log.trace "bestTime = ${bestTime}" 341 | } 342 | } 343 | } 344 | } 345 | } 346 | return commandInfo 347 | } 348 | 349 | ///////////////////////////////////// 350 | 351 | // return a getUniqueCommand() equivalent value 352 | def determineCommandFromResponse(parsedEvent, bodyString, body) { 353 | if (parsedEvent.bucket && parsedEvent.key) { 354 | return getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetSnapshot") 355 | } 356 | 357 | if (body) { 358 | if (body.data) { 359 | // has data 360 | if (body.data.sid != null) { return getUniqueCommand("SYNO.API.Auth", "Login") } 361 | if (bodyString.contains("maxVersion")) { return getUniqueCommand("SYNO.API.Info", "Query") } 362 | if (body.data.cameras != null) { return getUniqueCommand("SYNO.SurveillanceStation.Camera", "List") } 363 | //if (body.data.ptzPan != null) { return getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetCapability")} 364 | if (body.data.ptzPan != null) { return getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetCapabilityByCamId")} 365 | if ((body.data.total != null) && (body.data.offset != null)) 366 | { // this hack is annoying, they return the same thing if there are zero presets or patrols 367 | if ((state.commandList.size() > 0) 368 | && (getUniqueCommand(state.commandList.first()) == getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPreset"))) { 369 | return getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPreset") 370 | } 371 | else { 372 | return getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPatrol") 373 | } 374 | } 375 | } 376 | } 377 | 378 | return "" 379 | } 380 | 381 | def doesCommandReturnData(uniqueCommand) { 382 | switch (uniqueCommand) { 383 | case getUniqueCommand("SYNO.API.Auth", "Login"): 384 | case getUniqueCommand("SYNO.API.Info", "Query"): 385 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "List"): 386 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetCapability"): 387 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetCapabilityByCamId"): 388 | case getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPreset"): 389 | case getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPatrol"): 390 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetSnapshot"): 391 | return true 392 | } 393 | 394 | return false 395 | } 396 | 397 | // this process is overly complex handling async events from one IP 398 | // would be much better synchronous and not having to track / guess where things are coming from 399 | def locationHandler(evt) { 400 | def description = evt.description 401 | def hub = evt?.hubId 402 | 403 | def parsedEvent = parseEventMessage(description) 404 | parsedEvent << ["hub":hub] 405 | 406 | if ((parsedEvent.ip == convertIPtoHex(userip)) && (parsedEvent.port == convertPortToHex(userport))) 407 | { 408 | def bodyString = "" 409 | def body = null 410 | 411 | if (hub) { state.hub = hub } 412 | 413 | if (parsedEvent.headers && parsedEvent.body) 414 | { // DS RESPONSES 415 | def headerString = new String(parsedEvent.headers.decodeBase64()) 416 | bodyString = new String(parsedEvent.body.decodeBase64()) 417 | 418 | def type = (headerString =~ /Content-Type:.*/) ? (headerString =~ /Content-Type:.*/)[0] : null 419 | log.trace "DISKSTATION REPONSE TYPE: $type" 420 | if (type?.contains("text/plain")) 421 | { 422 | log.trace bodyString 423 | body = new groovy.json.JsonSlurper().parseText(bodyString) 424 | } else if (type?.contains("application/json")) { 425 | log.trace bodyString 426 | body = new groovy.json.JsonSlurper().parseText(bodyString) 427 | } else if (type?.contains("text/html")) { 428 | log.trace bodyString 429 | body = new groovy.json.JsonSlurper().parseText(bodyString.replaceAll("\\<.*?\\>", "")) 430 | } else { 431 | // unexpected data type 432 | log.trace "unexpected data type" 433 | if (state.commandList.size() > 0) { 434 | Map commandData = state.commandList.first() 435 | handleErrors(commandData, null) 436 | } 437 | return 438 | } 439 | if (body.error) { 440 | if (state.commandList.size() > 0) { 441 | Map commandData = state.commandList?.first() 442 | // should we generate an error for this type or ignore 443 | if ((getUniqueCommand(commandData) == getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPreset")) 444 | || (getUniqueCommand(commandData) == getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPatrol"))) 445 | { 446 | // ignore 447 | body.data = null 448 | } else { 449 | // don't ignore 450 | handleErrors(commandData, body.error) 451 | return 452 | } 453 | } else { 454 | // error on a command we don't care about 455 | handleErrorsIgnore(null, body.error) 456 | return 457 | } 458 | } 459 | } 460 | 461 | // gathered our incoming command data, see what we have 462 | def commandType = determineCommandFromResponse(parsedEvent, bodyString, body) 463 | 464 | // check if this is a command for the master 465 | if ((state.commandList.size() > 0) && (body != null) && (commandType != "")) 466 | { 467 | Map commandData = state.commandList.first() 468 | 469 | //log.trace "Logging command " + bodyString 470 | 471 | //log.trace "master waiting on " + getUniqueCommand(commandData) 472 | if (getUniqueCommand(commandData) == commandType) 473 | { 474 | // types match between incoming and what we wanted, handle it 475 | def finalizeCommand = true 476 | 477 | //try { 478 | if (body.success == true) 479 | { 480 | switch (getUniqueCommand(commandData)) { 481 | case getUniqueCommand("SYNO.API.Info", "Query"): 482 | def api = commandData.params.split("=")[1]; 483 | state.api.put((api), body.data[api]); 484 | break 485 | case getUniqueCommand("SYNO.API.Auth", "Login"): 486 | state.sid = body.data.sid 487 | break 488 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "List"): 489 | state.SSCameraList = body.data.cameras 490 | state.getCameraCapabilities = true; 491 | getCameraCapabilities() 492 | break 493 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetCapability"): 494 | // vendor=TRENDNet&model=TV-IP751WC 495 | def info = (commandData.params =~ /vendor=(.*)&model=(.*)/) 496 | if ((info[0][1] != null) && (info[0][2] != null)) { 497 | state.cameraCapabilities.put(makeCameraModelKey(info[0][1], info[0][2]), body.data) 498 | } 499 | break 500 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetCapabilityByCamId"): 501 | def cameraId = (commandData.params =~ /cameraId=([0-9]+)/) ? (commandData.params =~ /cameraId=([0-9]+)/)[0][1] : null 502 | if (cameraId) { 503 | def camera = state.SSCameraList.find { it.id.toString() == cameraId.toString() } 504 | if (camera) { 505 | def vendor = camera.additional.device.vendor.replaceAll(" ", "%20") 506 | def model = camera.additional.device.model.replaceAll(" ", "%20") 507 | state.cameraCapabilities.put(makeCameraModelKey(vendor, model), body.data) 508 | } else { 509 | log.trace "invalid camera id" 510 | } 511 | } 512 | break 513 | case getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPreset"): 514 | def cameraId = (commandData.params =~ /cameraId=([0-9]+)/) ? (commandData.params =~ /cameraId=([0-9]+)/)[0][1] : null 515 | if (cameraId) { state.cameraPresets[cameraId.toInteger()] = body.data?.presets } 516 | break 517 | case getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPatrol"): 518 | def cameraId = (commandData.params =~ /cameraId=([0-9]+)/) ? (commandData.params =~ /cameraId=([0-9]+)/)[0][1] : null 519 | if (cameraId) { state.cameraPatrols[cameraId.toInteger()] = body.data?.patrols } 520 | break 521 | default: 522 | log.debug "received invalid command: " + state.lastcommand 523 | finalizeCommand = false 524 | break 525 | } 526 | } else { 527 | // success = false 528 | log.debug "success = false but how did we know what command it was?" 529 | } 530 | 531 | // finalize and send next command 532 | if (finalizeCommand == true) { 533 | finalizeDiskstationCommand() 534 | } 535 | //} 536 | //catch (Exception err) { 537 | // log.trace "parse exception: ${err}" 538 | // handleErrors(commandData) 539 | //} 540 | // exit out, we've handled the message we wanted 541 | return 542 | } 543 | } 544 | // no master command waiting or not the one we wanted 545 | // is this a child message? 546 | 547 | if (commandType != "") { 548 | log.trace "event = ${description}" 549 | 550 | // see who wants this type (commandType) 551 | def commandInfo = getFirstChildCommand(commandType) 552 | 553 | if (commandInfo != null) { 554 | switch (commandType) { 555 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetSnapshot"): 556 | if (parsedEvent.bucket && parsedEvent.key){ 557 | log.trace "saving image to device" 558 | commandInfo?.child?.putImageInS3(parsedEvent) 559 | } 560 | return finalizeChildCommand(commandInfo) 561 | } 562 | } 563 | } 564 | 565 | // no one wants this type or unknown type 566 | if ((state.commandList.size() > 0) && (body != null)) 567 | { 568 | // we have master commands, maybe this is an error 569 | Map commandData = state.commandList.first() 570 | 571 | if (body.success == false) { 572 | def finalizeCommand = true 573 | 574 | switch (getUniqueCommand(commandData)) { 575 | case getUniqueCommand("SYNO.API.Info", "Query"): 576 | case getUniqueCommand("SYNO.API.Auth", "Login"): 577 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "List"): 578 | case getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetCapability"): 579 | handleErrors(commandData, null) 580 | break 581 | case getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPreset"): 582 | def cameraId = (commandData.params =~ /cameraId=([0-9]+)/) ? (commandData.params =~ /cameraId=([0-9]+)/)[0][1] : null 583 | if (cameraId) { state.cameraPresets[cameraId.toInteger()] = null } 584 | break 585 | case getUniqueCommand("SYNO.SurveillanceStation.PTZ", "ListPatrol"): 586 | def cameraId = (commandData.params =~ /cameraId=([0-9]+)/) ? (commandData.params =~ /cameraId=([0-9]+)/)[0][1] : null 587 | if (cameraId) { state.cameraPatrols[cameraId.toInteger()] = null } 588 | break 589 | default: 590 | log.debug "don't know now to handle this command " + state.lastcommand 591 | finalizeCommand = false 592 | break 593 | } 594 | if (finalizeCommand == true) { 595 | finalizeDiskstationCommand() 596 | } 597 | return 598 | } else { 599 | // if we get here, we likely just had a success for a message we don't care about 600 | } 601 | } 602 | 603 | // is this an empty GetSnapshot error? 604 | if (parsedEvent.requestId && !parsedEvent.headers) { 605 | def commandInfo = getFirstChildCommand(getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetSnapshot")) 606 | if (commandInfo) { 607 | log.trace "take image command returned an error" 608 | if ((state.lastErrorResend == null) || ((now() - state.lastErrorResend) > 15000)) { 609 | log.trace "resending to get real error message" 610 | state.lastErrorResend = now() 611 | state.doSnapshotResend = true 612 | sendDiskstationCommand(createCommandData("SYNO.SurveillanceStation.Camera", "GetSnapshot", "cameraId=${getDSCameraIDbyChild(commandInfo.child)}", 1)) 613 | } else { 614 | log.trace "not trying to resend again for more error info until later" 615 | } 616 | return 617 | } 618 | } 619 | 620 | // why are we here? 621 | log.trace "Did not use " + bodyString 622 | } 623 | } 624 | 625 | def handleErrors(commandData, errorData) { 626 | if (errorData) { 627 | log.trace "trying to handle error ${errorData}" 628 | } 629 | 630 | if (!state.SSCameraList) { 631 | // error while starting up 632 | switch (getUniqueCommand(commandData)) { 633 | case getUniqueCommand("SYNO.API.Info", "Query"): 634 | state.error = "Network Error. Check your ip address and port. You must access a local IP address and a non-https port." 635 | break 636 | case getUniqueCommand("SYNO.API.Auth", "Login"): 637 | state.error = "Login Error. Login failed. Check your login credentials." 638 | break 639 | default: 640 | state.error = "Error communicating with the Diskstation. Please check your settings and network connection. API = " + commandData.api + " command = " + commandData.command 641 | break 642 | } 643 | } else { 644 | // error later on 645 | checkForRedoLogin(commandData, errorData) 646 | } 647 | } 648 | 649 | def checkForRedoLogin(commandData, errorData) { 650 | if (errorData != null) { 651 | log.trace errorData 652 | if (errorData?.code == 102 || errorData?.code == 105) { 653 | log.trace "relogging in" 654 | executeLoginCommand() 655 | } else { 656 | if (commandData) { 657 | state.error = "Error communicating with the Diskstation. Please check your settings and network connection. API = " + commandData.api + " command = " + commandData.command 658 | } else { 659 | state.error = "Error communicating with the Diskstation. Please check your settings and network connection." 660 | } 661 | } 662 | } 663 | } 664 | 665 | def handleErrorsIgnore(commandData, errorData) { 666 | if (errorData) { 667 | log.trace "trying to handle error ${errorData}" 668 | } 669 | checkForRedoLogin(commandData, errorData) 670 | } 671 | 672 | private def parseEventMessage(Map event) { 673 | //handles attribute events 674 | log.trace "map event recd = " + event 675 | return event 676 | } 677 | 678 | private def parseEventMessage(String description) { 679 | def event = [:] 680 | def parts = description.split(',') 681 | parts.each { part -> 682 | part = part.trim() 683 | if (part.startsWith('bucket:')) { 684 | part -= "bucket:" 685 | def valueString = part.trim() 686 | if (valueString) { 687 | event.bucket = valueString 688 | } 689 | } 690 | else if (part.startsWith('key:')) { 691 | part -= "key:" 692 | def valueString = part.trim() 693 | if (valueString) { 694 | event.key = valueString 695 | } 696 | } 697 | else if (part.startsWith('ip:')) { 698 | part -= "ip:" 699 | def valueString = part.trim() 700 | if (valueString) { 701 | event.ip = valueString 702 | } 703 | } 704 | else if (part.startsWith('port:')) { 705 | part -= "port:" 706 | def valueString = part.trim() 707 | if (valueString) { 708 | event.port = valueString 709 | } 710 | } 711 | else if (part.startsWith('headers')) { 712 | part -= "headers:" 713 | def valueString = part.trim() 714 | if (valueString) { 715 | event.headers = valueString 716 | } 717 | } 718 | else if (part.startsWith('body')) { 719 | part -= "body:" 720 | def valueString = part.trim() 721 | if (valueString) { 722 | event.body = valueString 723 | } 724 | } 725 | else if (part.startsWith('requestId')) { 726 | part -= "requestId:" 727 | def valueString = part.trim() 728 | if (valueString) { 729 | event.requestId = valueString 730 | } 731 | } 732 | } 733 | 734 | event 735 | } 736 | 737 | private String convertIPtoHex(ipAddress) { 738 | String hex = ipAddress.tokenize( '.' ).collect { String.format( '%02x', it.toInteger() ) }.join() 739 | hex = hex.toUpperCase() 740 | return hex 741 | } 742 | 743 | private String convertPortToHex(port) { 744 | String hexport = port.toString().format( '%04x', port.toInteger() ) 745 | hexport = hexport.toUpperCase() 746 | return hexport 747 | } 748 | 749 | private String getDeviceId(ip, port) { 750 | def hosthex = convertIPtoHex(ip) 751 | def porthex = convertPortToHex(port) 752 | return "$hosthex:$porthex" 753 | } 754 | 755 | def installed() { 756 | log.debug "Installed with settings: ${settings}" 757 | initialize() 758 | } 759 | 760 | def updated() { 761 | log.debug "Updated with settings: ${settings}" 762 | initialize() 763 | } 764 | 765 | def initialize() { 766 | unsubscribe() 767 | state.subscribe = false 768 | state.getDSinfo = true 769 | 770 | state.lastMotion = [:] 771 | 772 | if (selectedCameras) { 773 | addCameras() 774 | } 775 | 776 | if(!state.subscribe) { 777 | log.trace "subscribe to location" 778 | subscribe(location, null, locationHandler, [filterEvents:false]) 779 | state.subscribe = true 780 | } 781 | } 782 | 783 | def uninstalled() { 784 | removeChildDevices(getChildDevices()) 785 | } 786 | 787 | private removeChildDevices(delete) { 788 | delete.each { 789 | deleteChildDevice(it.deviceNetworkId) 790 | } 791 | } 792 | 793 | def createCameraDNI(camera) { 794 | return (camera.name) 795 | } 796 | 797 | def addCameras() { 798 | selectedCameras.each { cameraIndex -> 799 | def newCamera = state.SSCameraList.find { it.id.toString() == cameraIndex.toString() } 800 | log.trace "newCamera = " + newCamera 801 | if (newCamera != null) { 802 | def newCameraDNI = createCameraDNI(newCamera) 803 | log.trace "newCameraDNI = " + newCameraDNI 804 | def d = getChildDevice(newCameraDNI) 805 | if(!d) { 806 | d = addChildDevice("swanny", "Diskstation Camera", newCameraDNI, state.hub, [label:"Diskstation ${newCamera?.name}"]) //, completedSetup: true 807 | log.trace "created ${d.displayName} with id $newCameraDNI" 808 | 809 | // set up device capabilities here ??? TODO ??? 810 | //d.setModel(newPlayer?.value.model) 811 | } else { 812 | log.trace "found ${d.displayName} with id $newCameraDNI already exists" 813 | } 814 | 815 | // set up even if already installed in case setup has changed 816 | d.initChild(state.cameraCapabilities[makeCameraModelKey(newCamera)]) 817 | } 818 | } 819 | 820 | } 821 | 822 | def createDiskstationURL(Map commandData) { 823 | String apipath = state.api.get(commandData.api)?.path 824 | if (apipath != null) { 825 | 826 | // add session id for most events (not api query or login) 827 | def session = "" 828 | if (!( (getUniqueCommand("SYNO.API.Info", "Query") == getUniqueCommand(commandData)) 829 | || (getUniqueCommand("SYNO.API.Auth", "Login") == getUniqueCommand(commandData)) ) ) { 830 | session = "&_sid=" + state.sid 831 | } 832 | 833 | if ((state.api.get(commandData.api)?.minVersion <= commandData.version) && (state.api.get(commandData.api)?.maxVersion >= commandData.version)) { 834 | def url = "/webapi/${apipath}?api=${commandData.api}&method=${commandData.command}&version=${commandData.version}${session}&${commandData.params}" 835 | return url 836 | } else { 837 | log.trace "need a higher DS api version" 838 | } 839 | 840 | } else { 841 | // error!!!??? 842 | log.trace "Unable to send to api " + commandData.api 843 | log.trace "Available APIs are " + state.api 844 | } 845 | return null 846 | } 847 | 848 | def createHubAction(Map commandData) { 849 | 850 | String deviceNetworkId = getDeviceId(userip, userport) 851 | String ip = userip + ":" + userport 852 | 853 | try { 854 | def url = createDiskstationURL(commandData) 855 | if (url != null) { 856 | def acceptType = "application/json, text/plain, text/html, */*" 857 | if (commandData.acceptType) { 858 | acceptType = commandData.acceptType 859 | } 860 | 861 | def hubaction = new physicalgraph.device.HubAction( 862 | """GET ${url} HTTP/1.1\r\nHOST: ${ip}\r\nAccept: ${acceptType}\r\n\r\n""", 863 | physicalgraph.device.Protocol.LAN, "${deviceNetworkId}") 864 | if (getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetSnapshot") == getUniqueCommand(commandData)) { 865 | if (state.doSnapshotResend) { 866 | state.doSnapshotResend = false 867 | } else { 868 | hubaction.options = [outputMsgToS3:true] 869 | } 870 | } 871 | return hubaction 872 | } else { 873 | return null 874 | } 875 | } 876 | catch (Exception err) { 877 | log.debug "error sending message: " + err 878 | } 879 | return null 880 | } 881 | 882 | def sendDiskstationCommand(Map commandData) { 883 | def hubaction = createHubAction(commandData) 884 | if (hubaction) { 885 | sendHubCommand(hubaction) 886 | } 887 | } 888 | 889 | def createCommandData(String api, String command, String params, int version) { 890 | def commandData = [:] 891 | commandData.put('api', api) 892 | commandData.put('command', command) 893 | commandData.put('params', params) 894 | commandData.put('version', version) 895 | commandData.put('time', now()) 896 | 897 | if (getUniqueCommand("SYNO.SurveillanceStation.Camera", "GetSnapshot") == getUniqueCommand(commandData)) { 898 | commandData.put('acceptType', "image/jpeg"); 899 | } 900 | 901 | return commandData 902 | } 903 | 904 | def queueDiskstationCommand(String api, String command, String params, int version) { 905 | 906 | def commandData = createCommandData(api, command, params, version) 907 | 908 | if (doesCommandReturnData(getUniqueCommand(commandData))) { 909 | // queue since we get data 910 | state.commandList.add(commandData) 911 | 912 | // list was empty, send now 913 | if (state.commandList.size() == 1) { 914 | sendDiskstationCommand(state.commandList.first()) 915 | } else { 916 | // something else waiting 917 | if ((now() - state.commandList.first().time) > 15000) { 918 | log.trace "waiting command being cancelled = " + state.commandList.first() 919 | finalizeDiskstationCommand() 920 | } 921 | } 922 | } else { 923 | sendDiskstationCommand(commandData) 924 | } 925 | } 926 | 927 | def finalizeDiskstationCommand() { 928 | //log.trace "removing " + state.commandList.first().command 929 | state.commandList.remove(0) 930 | 931 | // may need to handle some child stuff based on this command 932 | pollChildren() 933 | 934 | // send next command if list was full 935 | if (state.commandList.size() > 0) { 936 | sendDiskstationCommand(state.commandList.first()) 937 | } 938 | } 939 | 940 | private def clearDiskstationCommandQueue() { 941 | state.commandList.clear() 942 | } 943 | 944 | def webNotifyCallback() { 945 | log.trace "motion callback" 946 | 947 | if (params?.msg?.contains("Test message from Synology")) { 948 | state.motionTested = true 949 | log.debug "Test message received" 950 | } 951 | 952 | // Camera Foscam1 on DiskStation has detected motion 953 | def motionMatch = (params?.msg =~ /Camera (.*) on (.*) has detected motion/) 954 | if (motionMatch) { 955 | def thisCamera = state.SSCameraList.find { it.name.toString() == motionMatch[0][1].toString() } 956 | if (thisCamera) { 957 | def cameraDNI = createCameraDNI(thisCamera) 958 | if (cameraDNI) { 959 | if ((state.lastMotion[cameraDNI] == null) || ((now() - state.lastMotion[cameraDNI]) > 1000)) { 960 | state.lastMotion[cameraDNI] = now() 961 | 962 | def d = getChildDevice(cameraDNI) 963 | if (d && d.currentValue("motion") == "inactive") { 964 | log.trace "motion on child device: " + d 965 | d.motionActivated() 966 | if (d.currentValue("autoTake") == "on") { 967 | log.trace "taking motion image for child" 968 | d.take() 969 | } 970 | handleMotion() 971 | } 972 | } 973 | } 974 | } 975 | } 976 | } 977 | 978 | def checkMotionDeactivate(child) { 979 | def timeRemaining = null 980 | def cameraDNI = child.deviceNetworkId 981 | 982 | try { 983 | def delay = (motionOffDelay) ? motionOffDelay : 5 984 | delay = delay * 60 985 | if (state.lastMotion[cameraDNI] != null) { 986 | timeRemaining = delay - ((now() - state.lastMotion[cameraDNI])/1000) 987 | } 988 | } 989 | catch (Exception err) { 990 | timeRemaining = 0 991 | } 992 | 993 | log.debug "checkMotionDeactivate ${cameraDNI} timeRemaining = ${timeRemaining}" 994 | 995 | // we can end motion early to avoid unresponsiveness later 996 | if ((timeRemaining != null) && (timeRemaining < 15)) { 997 | child.motionDeactivate() 998 | state.lastMotion[cameraDNI] = null 999 | timeRemaining = null 1000 | log.debug "checkMotionDeactivate ${cameraDNI} deactivated" 1001 | } 1002 | return timeRemaining 1003 | } 1004 | 1005 | def handleMotion() { 1006 | def children = getChildDevices() 1007 | def nextTime = 1000000; 1008 | log.debug "handleMotion" 1009 | 1010 | children.each { 1011 | def newTime = checkMotionDeactivate(it) 1012 | if ((newTime != null) && (newTime < nextTime)) { 1013 | nextTime = newTime 1014 | } 1015 | } 1016 | 1017 | log.debug "handleMotion nextTime = ${nextTime}" 1018 | if ((nextTime != 1000000)){ 1019 | log.trace "nextTime = " + nextTime 1020 | nextTime = (nextTime >= 25) ? nextTime : 25 1021 | runIn((nextTime+5).toInteger(), "handleMotion") 1022 | } 1023 | } 1024 | 1025 | /////////CHILD DEVICE METHODS 1026 | 1027 | def getDSCameraIDbyChild(childDevice) { 1028 | return getDSCameraIDbyName(childDevice.device?.deviceNetworkId) 1029 | } 1030 | 1031 | def getDSCameraIDbyName(String name) { 1032 | if (name) { 1033 | def thisCamera = state.SSCameraList.find { createCameraDNI(it).toString() == name.toString() } 1034 | return thisCamera?.id 1035 | } else { 1036 | return null 1037 | } 1038 | } 1039 | 1040 | def getNumPresets(childDevice) { 1041 | def childId = getDSCameraIDbyChild(childDevice) 1042 | if ((childId != null) && (childId <= state.cameraPresets.size()) && state.cameraPresets[childId]) { 1043 | return state.cameraPresets[childId].size() 1044 | } 1045 | return 0 1046 | } 1047 | 1048 | 1049 | def getPresetId(childDevice, index) { 1050 | def childId = getDSCameraIDbyChild(childDevice) 1051 | if ((childId != null) && (childId <= state.cameraPresets.size())) { 1052 | if (index <= state.cameraPresets[childId]?.size()) { 1053 | return state.cameraPresets[childId][index-1]?.id 1054 | } 1055 | } 1056 | return null 1057 | } 1058 | 1059 | def getPresetString(childDevice, index) { 1060 | def childId = getDSCameraIDbyChild(childDevice) 1061 | if ((childId != null) && (childId <= state.cameraPresets.size())) { 1062 | if ((index > 0) && (index <= state.cameraPresets[childId]?.size())) { 1063 | return state.cameraPresets[childId][index-1]?.name 1064 | } 1065 | } 1066 | return "N/A" 1067 | } 1068 | 1069 | def getPresetIdByString(childDevice, name) { 1070 | def childId = getDSCameraIDbyChild(childDevice) 1071 | if (state.cameraPresets[childId] != null) { 1072 | def preset = state.cameraPresets[childId].find { it.name.toString().equalsIgnoreCase(name.toString()) } 1073 | return preset?.id 1074 | } 1075 | return null 1076 | } 1077 | 1078 | def getNumPatrols(childDevice) { 1079 | def childId = getDSCameraIDbyChild(childDevice) 1080 | if ((childId != null) && (childId <= state.cameraPatrols.size()) && state.cameraPatrols[childId]) { 1081 | return state.cameraPatrols[childId].size() 1082 | } 1083 | return 0 1084 | } 1085 | 1086 | def getPatrolId(childDevice, index) { 1087 | def childId = getDSCameraIDbyChild(childDevice) 1088 | if ((childId != null) && (childId <= state.cameraPatrols.size())) { 1089 | if (index <= state.cameraPatrols[childId]?.size()) { 1090 | return state.cameraPatrols[childId][index-1]?.id 1091 | } 1092 | } 1093 | return null 1094 | } 1095 | 1096 | def getPatrolString(childDevice, index) { 1097 | def childId = getDSCameraIDbyChild(childDevice) 1098 | if ((childId != null) && (childId <= state.cameraPatrols.size())) { 1099 | if ((index > 0) && (index <= state.cameraPatrols[childId]?.size())) { 1100 | return state.cameraPatrols[childId][index-1]?.name 1101 | } 1102 | } 1103 | return "N/A" 1104 | } 1105 | 1106 | def getPatrolIdByString(childDevice, name) { 1107 | def childId = getDSCameraIDbyChild(childDevice) 1108 | if (state.cameraPatrols[childId] != null) { 1109 | def patrol = state.cameraPatrols[childId].find { it.name.toString().equalsIgnoreCase(name.toString()) } 1110 | return patrol?.id 1111 | } 1112 | return null 1113 | } 1114 | 1115 | import groovy.time.TimeCategory 1116 | 1117 | def refreshCamera(childDevice) { 1118 | // this is called by the child, let's just come back here in a second from the SmartApp instead so we have the right context 1119 | //runIn(8, "startPolling") 1120 | def timer = new Date() 1121 | use(TimeCategory) { 1122 | timer = timer + 3.second 1123 | } 1124 | runOnce(timer, "startPolling") 1125 | } 1126 | 1127 | def startPolling() { 1128 | state.refreshIterations = 0 1129 | pollChildren() 1130 | } 1131 | 1132 | def pollChildren(){ 1133 | def children = getChildDevices() 1134 | 1135 | children.each { 1136 | //log.trace "refreshState = " + getRefreshState(it) 1137 | 1138 | // step 2 - check if they are waiting to be told of their refresh 1139 | if (waitingRefresh(it) == true) { 1140 | // waiting on refresh 1141 | if (state.commandList.size() == 0) { 1142 | def childObj = it; 1143 | def thisCamera = state.SSCameraList.find { createCameraDNI(it).toString() == childObj.deviceNetworkId.toString() } 1144 | if (thisCamera) { 1145 | it.doRefreshUpdate(state.cameraCapabilities[makeCameraModelKey(thisCamera)]) 1146 | } 1147 | } 1148 | } 1149 | 1150 | // step 1 - check if they are wanting to start a refresh 1151 | if (wantRefresh(it) == true) { 1152 | // do child refresh 1153 | def childObj = it; 1154 | def thisCamera = state.SSCameraList.find { createCameraDNI(it).toString() == childObj.deviceNetworkId.toString() } 1155 | if (thisCamera) { 1156 | updateCameraInfo(thisCamera) 1157 | } 1158 | } 1159 | } 1160 | } 1161 | 1162 | // parent checks this to say if we want a refresh 1163 | def wantRefresh(child) { 1164 | def want = (child.currentState("refreshState")?.value == "want") 1165 | if (want) { 1166 | child.doRefreshWait() 1167 | } 1168 | return (want) 1169 | } 1170 | 1171 | def getRefreshState(child) { 1172 | return (child.currentState("refreshState")?.value) 1173 | } 1174 | 1175 | def waitingRefresh(child) { 1176 | return (child.currentState("refreshState")?.value == "waiting") 1177 | } 1178 | -------------------------------------------------------------------------------- /DiskStation/OtherSmartApps/CameraControlExample.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Synology Diskstation Camera Control 3 | * 4 | * Copyright 2014 swanny 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "Synology Diskstation Camera Control", 18 | namespace: "swanny", 19 | author: "swanny", 20 | description: "Control the cameras based on presence", 21 | category: "My Apps", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") 24 | 25 | 26 | preferences { 27 | section("Who?") { 28 | input "selectedSensors", "capability.presenceSensor", title: "Presense sensors?", multiple: true 29 | } 30 | section ("Cameras to adjust?") { 31 | input "selectedCameras", "capability.imageCapture", title: "Cameras?", multiple: true 32 | } 33 | } 34 | 35 | def installed() { 36 | log.debug "Installed with settings: ${settings}" 37 | 38 | initialize() 39 | } 40 | 41 | def updated() { 42 | log.debug "Updated with settings: ${settings}" 43 | 44 | unsubscribe() 45 | initialize() 46 | } 47 | 48 | def initialize() { 49 | state.home = true 50 | subscribe(selectedSensors, "presence", presence) 51 | } 52 | 53 | def presence(evt) 54 | { 55 | if (state.home == false) { 56 | if (evt.value == "present") { 57 | selectedCameras.each { camera -> 58 | log.trace "go home" 59 | camera.home() 60 | } 61 | state.home = true 62 | } 63 | } else { 64 | def allGone = true 65 | selectedSensors.each { sensor -> 66 | if (sensor.currentValue("presence") == "present") { 67 | allGone = false 68 | } 69 | } 70 | 71 | log.trace "all gone = $allGone" 72 | 73 | if (allGone == true) { 74 | selectedCameras.each { camera -> 75 | log.trace "go middle" 76 | camera.presetGoName("middle") 77 | } 78 | state.home = false 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /DiskStation/OtherSmartApps/PhotoBurstv2.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Photo Burst When... 3 | * 4 | * Author: SmartThings, modified by Dave Swanson 5 | * 6 | * Date: 2015-05-25 7 | */ 8 | 9 | definition( 10 | name: "Photo Burst When v2...", 11 | namespace: "swanny", 12 | author: "swanny", 13 | description: "Take a burst of photos and send a push notification when...", 14 | category: "SmartThings Labs", 15 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/photo-burst-when.png", 16 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/photo-burst-when@2x.png" 17 | ) 18 | 19 | preferences { 20 | section("Choose one or more, when..."){ 21 | input "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true 22 | input "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true 23 | input "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true 24 | input "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true 25 | input "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true 26 | input "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true 27 | } 28 | section("Take a burst of pictures") { 29 | input "camera", "capability.imageCapture" 30 | input "presetName", "string", title: "Optional preset to go to", required: false 31 | input "presetDelay", "number", title: "Seconds until preset is done", defaultValue:0 32 | input "burstCount", "number", title: "How many? (default 5)", defaultValue:5 33 | } 34 | section("Then send this message in a push notification"){ 35 | input "messageText", "text", title: "Message Text", required: false 36 | } 37 | section("And as text message to this number (optional)"){ 38 | input("recipients", "contact", title: "Send notifications to") { 39 | input "phone", "phone", title: "Phone Number", required: false 40 | } 41 | } 42 | 43 | } 44 | 45 | def installed() { 46 | log.debug "Installed with settings: ${settings}" 47 | subscribeToEvents() 48 | } 49 | 50 | def updated() { 51 | log.debug "Updated with settings: ${settings}" 52 | unsubscribe() 53 | subscribeToEvents() 54 | } 55 | 56 | def subscribeToEvents() { 57 | subscribe(contact, "contact.open", sendMessage) 58 | subscribe(acceleration, "acceleration.active", sendMessage) 59 | subscribe(motion, "motion.active", sendMessage) 60 | subscribe(mySwitch, "switch.on", sendMessage) 61 | subscribe(arrivalPresence, "presence.present", sendMessage) 62 | subscribe(departurePresence, "presence.not present", sendMessage) 63 | } 64 | 65 | def sendMessage(evt) { 66 | log.debug "$evt.name: $evt.value, $messageText" 67 | 68 | if ((presetName != null) && (presetName != "")) { 69 | log.trace "go to " + presetName 70 | camera.presetGoName(presetName) 71 | } 72 | 73 | def takeDelay 74 | (1..((burstCount ?: 5))).each { 75 | takeDelay = (((presetDelay ?: 0)*1000) + (500 * it) - 450) 76 | log.trace "delay = " + takeDelay 77 | camera.take(delay: takeDelay) // using 450 so there is always a tiny delay 78 | } 79 | 80 | if (location.contactBookEnabled) { 81 | sendNotificationToContacts(messageText, recipients) 82 | } 83 | else { 84 | if (messageText) { 85 | sendPush(messageText) 86 | } 87 | if (phone) { 88 | sendSms(phone, messageText) 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /DiskStation/readme.md: -------------------------------------------------------------------------------- 1 | 2 | ## Features 3 | 4 | Connect to Synology Diskstation / Surveillance Station on your local network and create camera devices for each camera in the Survellance Station application. 5 | 6 | Camera devices support the following capabilities: 7 | - Image Capture -- Image capture uses the standard take command 8 | - Motion Detection -- Motion detection status lasts for a user specified number of minutes past the last motion sent by the Diskstation 9 | - Record -- Using the device button or switch on/off commands to start and stop recording 10 | - Refresh -- Use the refresh button / command when you change presets or patrols 11 | 12 | The follow commands are also supported from SmartApps: 13 | - "left", "right", "up", and "down" 14 | - "zoomIn" and "zoomOut" 15 | - "home" 16 | - "presetGoName", ["string"] 17 | - "patrolGoName", ["string"] 18 | - "autoTakeOn" and "autoTakeOff" 19 | 20 | ## Installation 21 | 22 | To set up the your Synology Diskstation cameras from Surveillance Station as SmartThings devices, follow these steps: 23 | 24 | Set up the SmartApp: 25 | * Create a new SmartApp 26 | * Enter any values in the required * fields 27 | * Click the button "Enable OAuth in Smart App" 28 | * Create the Smart App 29 | * Copy the code from DiskstationConnect.groovy over the code of your SmartApp 30 | * Save and Publish For Me 31 | 32 | Set up the Device Type: 33 | * Create a new Device Type 34 | * Enter any values in the required * fields 35 | * Create the Device Type 36 | * Copy the code from DiskstationCamera.groovy over the code of your Device Type 37 | * Save and Publish For Me 38 | 39 | Connect to your DiskStation: 40 | * Open your SmartThings application on your iPhone or Android device 41 | * Go to My Apps and Choose Diskstation (Connect) 42 | * If you don't see it in My Apps, kill the SmartThings application and restart it 43 | * Follow the instructions in the App for the rest of the details 44 | 45 | If you add new cameras to your system, go through the Diskstation (Connect) app again to add the new cameras. If you already set up motion detection, you do not need to do it again. 46 | 47 | -------------------------------------------------------------------------------- /WirelessTags/WirelessTagsConnect.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Wireless Tags (Connect) 3 | * 4 | * Copyright 2014 Dave Swanson (swanny) 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | definition( 17 | name: "Wireless Tags (Connect)", 18 | namespace: "swanny", 19 | author: "swanny", 20 | description: "Wireless Tags connection", 21 | category: "Convenience", 22 | iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", 23 | iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", 24 | oauth: true) 25 | 26 | 27 | preferences { 28 | page(name: "auth", title: "Wireless Tags", nextPage:"deviceList", content:"authPage", uninstall: true) 29 | page(name: "deviceList", title: "Wireless Tags", content:"wirelessDeviceList", install:true) 30 | } 31 | 32 | mappings { 33 | path("/swapToken") { 34 | action: [ 35 | GET: "swapToken" 36 | ] 37 | } 38 | path("/urlcallback") { 39 | action: [ 40 | GET: "handleUrlCallback", 41 | POST: "handleUrlCallback" 42 | ] 43 | } 44 | } 45 | 46 | def handleUrlCallback () { 47 | log.trace "url callback" 48 | debugEvent ("url callback: $params", true) 49 | 50 | def id = params.id.toInteger() 51 | def type = params.type 52 | 53 | def dni = getTagUUID(id) 54 | 55 | if (dni) { 56 | def d = getChildDevice(dni) 57 | 58 | if(d) 59 | { 60 | def data = null 61 | 62 | switch (type) { 63 | case "oor": data = [presence: "not present"]; break 64 | case "back_in_range": data = [presence: "present"]; break 65 | case "motion_detected": data = [acceleration: "active", motion: "active"]; startMotionTimer(d); break 66 | 67 | // motion timeout callback is not working currently in WST 68 | // case "motion_timedout": data = [acceleration: "inactive", motion: "inactive"]; break 69 | 70 | case "door_opened": data = [contact: "open"]; break 71 | case "door_closed": data = [contact: "closed"]; break 72 | case "water_detected": data = [water : "wet"]; break 73 | case "water_dried": data = [water : "dry"]; break 74 | } 75 | 76 | log.trace "callback action = " + data?.toString() 77 | 78 | if (data) { 79 | d.generateEvent(data) 80 | } 81 | } 82 | } 83 | } 84 | 85 | def authPage() 86 | { 87 | if(!atomicState.accessToken) 88 | { 89 | log.debug "about to create access token" 90 | createAccessToken() 91 | atomicState.accessToken = state.accessToken 92 | } 93 | 94 | // oauth docs = http://www.mytaglist.com/eth/oauth2_apps.html 95 | def description = "Required" 96 | def uninstallAllowed = false 97 | def oauthTokenProvided = false 98 | 99 | if(atomicState.authToken) 100 | { 101 | description = "You are connected." 102 | uninstallAllowed = true 103 | oauthTokenProvided = true 104 | } 105 | 106 | def redirectUrl = oauthInitUrl() 107 | log.debug "RedirectUrl = ${redirectUrl}" 108 | 109 | // get rid of next button until the user is actually auth'd 110 | if (!oauthTokenProvided) { 111 | 112 | return dynamicPage(name: "auth", title: "Login", nextPage:null, uninstall:uninstallAllowed) { 113 | section(){ 114 | paragraph "Tap below to log in to the Wireless Tags service and authorize SmartThings access." 115 | href url:redirectUrl, style:"embedded", required:true, title:"Wireless Tags", description:description 116 | } 117 | } 118 | 119 | } else { 120 | 121 | return dynamicPage(name: "auth", title: "Log In", nextPage:"deviceList", uninstall:uninstallAllowed) { 122 | section(){ 123 | paragraph "Tap Next to continue to setup your devices." 124 | href url:redirectUrl, style:"embedded", state:"complete", title:"Wireless Tags", description:description 125 | } 126 | } 127 | 128 | } 129 | 130 | } 131 | 132 | def wirelessDeviceList() 133 | { 134 | def availDevices = getWirelessTags() 135 | 136 | def p = dynamicPage(name: "deviceList", title: "Select Your Devices", uninstall: true) { 137 | section(""){ 138 | paragraph "Tap below to see the list of Wireless Tag devices available in your Wireless Tags account and select the ones you want to connect to SmartThings." 139 | paragraph "When you hit Done, the setup can take as much as 10 seconds per device selected." 140 | input(name: "devices", title:"", type: "enum", required:true, multiple:true, description: "Tap to choose", metadata:[values:availDevices]) 141 | paragraph "Note that only the wireless motion sensor tags (8- and 13-bit) have been tested. The PIR/Reed Kumosensor will likely work with the same Device Type and will automatically attempt to use it. The water sensors should work with the specific device type but they have not been tested." 142 | } 143 | section("Tuning Tips", hidden: true, hideable: true) { 144 | paragraph "A lot of settings can be tuned using your mytaglist.com account: " 145 | paragraph "For the sensing of motion and open/close, it is recommended to turn up the motion sensor responsiveness but it will use your battery faster." 146 | paragraph "The temperature and humidity values can be calibrated using the mytaglist web UI." 147 | } 148 | section("Optional Settings", hidden: true, hideable: true) { 149 | input "pollTimer", "number", title:"Minutes between poll updates of the sensors", defaultValue:5 150 | } 151 | section([mobileOnly:true]) { 152 | paragraph "If you have more than 5 or 6 sensors, you may need to create multiple instances of this SmartApp with only about 5 devices selected in each instance. Use a unique name here to create multiple apps." 153 | label title: "Assign a name for this SmartApp instance (optional)", required: false 154 | } 155 | } 156 | 157 | return p 158 | } 159 | 160 | def getWirelessTags() 161 | { 162 | def result = getTagStatusFromServer() 163 | 164 | def availDevices = [:] 165 | result?.each { device -> 166 | def dni = device?.uuid 167 | availDevices[dni] = device?.name 168 | } 169 | 170 | log.debug "devices: $availDevices" 171 | 172 | return availDevices 173 | } 174 | 175 | 176 | def installed() { 177 | initialize() 178 | } 179 | 180 | def updated() { 181 | unsubscribe() 182 | initialize() 183 | } 184 | 185 | def getChildNamespace() { "swanny" } 186 | def getChildName(def tagInfo) { 187 | def deviceType = "Wireless Tag Motion" 188 | if (tagInfo) { 189 | switch (tagInfo.tagType) { 190 | case 32: 191 | case 33: 192 | deviceType = "Wireless Tag Water" 193 | break; 194 | // add new device types here 195 | } 196 | } 197 | return deviceType 198 | } 199 | 200 | def initialize() { 201 | 202 | unschedule() 203 | 204 | def curDevices = devices.collect { dni -> 205 | 206 | def d = getChildDevice(dni) 207 | 208 | def tag = atomicState.tags.find { it.uuid == dni } 209 | 210 | if(!d) 211 | { 212 | d = addChildDevice(getChildNamespace(), getChildName(tag), dni, null, [label:tag?.name]) 213 | d.initialSetup() 214 | log.debug "created ${d.displayName} $dni" 215 | } 216 | else 217 | { 218 | log.debug "found ${d.displayName} $dni already exists" 219 | d.updated() 220 | } 221 | 222 | if (d) { 223 | // configure device 224 | setupCallbacks(d, tag) 225 | } 226 | 227 | return dni 228 | } 229 | 230 | def delete 231 | // Delete any that are no longer in settings 232 | if(!curDevices) 233 | { 234 | delete = getAllChildDevices() 235 | } 236 | else 237 | { 238 | delete = getChildDevices().findAll { !curDevices.contains(it.deviceNetworkId) } 239 | } 240 | 241 | delete.each { deleteChildDevice(it.deviceNetworkId) } 242 | 243 | if (atomicState.tags == null) { atomicState.tags = [:] } 244 | 245 | pollHandler() 246 | 247 | // set up internal poll timer 248 | if (pollTimer == null) pollTimer = 5 249 | 250 | log.trace "setting poll to ${pollTimer}" 251 | schedule("0 0/${pollTimer.toInteger()} * * * ?", pollHandler) 252 | } 253 | 254 | def oauthInitUrl() 255 | { 256 | log.debug "oauthInitUrl" 257 | def stcid = getSmartThingsClientId(); 258 | 259 | atomicState.oauthInitState = UUID.randomUUID().toString() 260 | 261 | def oauthParams = [ 262 | client_id: stcid, 263 | state: atomicState.oauthInitState, 264 | redirect_uri: buildRedirectUrl() 265 | ] 266 | 267 | return "https://www.mytaglist.com/oauth2/authorize.aspx?" + toQueryString(oauthParams) 268 | } 269 | 270 | def buildRedirectUrl() 271 | { 272 | log.debug "buildRedirectUrl" 273 | return apiServerUrl("/api/token/${atomicState.accessToken}/smartapps/installations/${app.id}/swapToken") 274 | } 275 | 276 | def swapToken() 277 | { 278 | log.debug "swapping token: $params" 279 | 280 | def code = params.code 281 | def oauthState = params.state 282 | 283 | // TODO: verify oauthState == atomicState.oauthInitState 284 | def stcid = getSmartThingsClientId() 285 | 286 | def refreshParams = [ 287 | method: 'POST', 288 | uri: "https://www.mytaglist.com/", 289 | path: "/oauth2/access_token.aspx", 290 | query: [ 291 | grant_type: "authorization_code", 292 | client_id: stcid, 293 | client_secret: "e965dcc2-1657-498b-b425-1ab42074b400", 294 | code: params.code, 295 | redirect_uri: buildRedirectUrl() 296 | ], 297 | ] 298 | 299 | try{ 300 | def jsonMap 301 | httpPost(refreshParams) { resp -> 302 | if(resp.status == 200) 303 | { 304 | jsonMap = resp.data 305 | if (resp.data) { 306 | atomicState.authToken = jsonMap?.access_token 307 | } else { 308 | log.trace "error = " + resp 309 | } 310 | } else { 311 | log.trace "response = " + resp 312 | } 313 | } 314 | } catch ( ex ) { 315 | atomicState.authToken = null 316 | log.trace "error = " + ex 317 | } 318 | 319 | def html = """ 320 | 321 | 322 | 323 | 324 | Withings Connection 325 | 375 | 376 | 377 |
378 | wireless tags icon 379 | connected device icon 380 | SmartThings logo 381 |

Your Wireless Tags Account is now connected to SmartThings!

382 |

Click 'Done' to finish setup.

383 |
384 | 385 | 386 | """ 387 | 388 | render contentType: 'text/html', data: html 389 | } 390 | 391 | def getEventStates() { 392 | def tagEventStates = [ 0: "Disarmed", 1: "Armed", 2: "Moved", 3: "Opened", 4: "Closed", 5: "Detected", 6: "Timed Out", 7: "Stabilizing..." ] 393 | return tagEventStates 394 | } 395 | 396 | def pollHandler() { 397 | log.trace "pollHandler" 398 | getTagStatusFromServer() 399 | updateAllDevices() 400 | } 401 | 402 | def updateAllDevices() { 403 | atomicState.tags.each {device -> 404 | def dni = device.uuid 405 | def d = getChildDevice(dni) 406 | 407 | if(d) 408 | { 409 | updateDeviceStatus(device, d) 410 | } 411 | } 412 | } 413 | 414 | def pollSingle(def child) { 415 | log.trace "pollSingle" 416 | getTagStatusFromServer() 417 | 418 | def device = atomicState.tags.find { it.uuid == child.device.deviceNetworkId } 419 | 420 | if (device) { 421 | updateDeviceStatus(device, child) 422 | } 423 | } 424 | 425 | def updateDeviceStatus(def device, def d) { 426 | def tagEventStates = getEventStates() 427 | 428 | // parsing data here 429 | def data = [ 430 | tagType: convertTagTypeToString(device), 431 | temperature: device.temperature.toDouble().round(1), 432 | rssi: ((Math.max(Math.min(device.signaldBm, -60),-100)+100)*100/40).toDouble().round(), 433 | presence: ((device.OutOfRange == true) ? "not present" : "present"), 434 | battery: (device.batteryVolt*100/3).toDouble().round(), 435 | switch: ((device.lit == true) ? "on" : "off"), 436 | humidity: (device.cap).toDouble().round(), 437 | contact : (tagEventStates[device.eventState] == "Opened") ? "open" : "closed", 438 | acceleration : (tagEventStates[device.eventState] == "Moved") ? "active" : "inactive", 439 | motion : (tagEventStates[device.eventState] == "Moved") ? "active" : "inactive", 440 | water : (device.shorted == true) ? "wet" : "dry" 441 | ] 442 | d.generateEvent(data) 443 | } 444 | 445 | def getPollRateMillis() { return 2 * 1000 } 446 | 447 | def getTagStatusFromServer() 448 | { 449 | def timeSince = (atomicState.lastPoll != null) ? now() - atomicState.lastPoll : 1000*1000 450 | 451 | if ((atomicState.tags == null) || (timeSince > getPollRateMillis())) { 452 | def result = postMessage("/ethClient.asmx/GetTagList", null) 453 | atomicState.tags = result?.d 454 | atomicState.lastPoll = now() 455 | 456 | } else { 457 | log.trace "waiting to refresh from server" 458 | } 459 | return atomicState.tags 460 | } 461 | 462 | 463 | // Poll Child is invoked from the Child Device itself as part of the Poll Capability 464 | def pollChild( child ) 465 | { 466 | pollSingle(child) 467 | 468 | return null 469 | } 470 | 471 | def refreshChild( child ) 472 | { 473 | def id = getTagID(child.device.deviceNetworkId) 474 | 475 | if (id != null) { 476 | // PingAllTags didn't reliable update the tag we wanted so just ping the one 477 | Map query = [ 478 | "id": id 479 | ] 480 | postMessage("/ethClient.asmx/PingTag", query) 481 | pollSingle( child ) 482 | } else { 483 | log.trace "Could not find tag" 484 | } 485 | 486 | return null 487 | } 488 | 489 | def postMessage(path, def query) { 490 | log.trace "sending ${path}" 491 | 492 | def message 493 | if (query != null) { 494 | if (query instanceof String) { 495 | message = [ 496 | method: 'POST', 497 | uri: "https://www.mytaglist.com/", 498 | path: path, 499 | headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], 500 | body: query 501 | ] 502 | } else { 503 | message = [ 504 | method: 'POST', 505 | uri: "https://www.mytaglist.com/", 506 | path: path, 507 | headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], 508 | body: toJson(query) 509 | ] 510 | } 511 | } else { 512 | message = [ 513 | method: 'POST', 514 | uri: "https://www.mytaglist.com/", 515 | path: path, 516 | headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"] 517 | ] 518 | } 519 | 520 | //dumpMsg(message) 521 | 522 | def jsonMap 523 | try{ 524 | httpPost(message) { resp -> 525 | if(resp.status == 200) 526 | { 527 | if (resp.data) { 528 | log.trace "success" 529 | jsonMap = resp.data 530 | } else { 531 | log.trace "error = " + resp 532 | } 533 | } else { 534 | log.debug "http status: ${resp.status}" 535 | if (resp.status == 500 && resp.data.status.code == 14) 536 | { 537 | log.debug "Need to refresh auth token?" 538 | atomicState.authToken = null 539 | } 540 | else 541 | { 542 | log.error "Authentication error, invalid authentication method, lack of credentials, etc." 543 | } 544 | } 545 | } 546 | } catch ( ex ) { 547 | //atomicState.authToken = null 548 | log.trace "error = " + ex 549 | } 550 | 551 | return jsonMap 552 | } 553 | 554 | def setSingleCallback(def tag, Map callback, def type) { 555 | 556 | def parameters = "?type=$type&" 557 | 558 | switch (type) { 559 | case "water_dried": 560 | case "water_detected": 561 | // 2 params 562 | parameters = parameters + "name={0}&id={1}" 563 | break; 564 | case "oor": 565 | case "back_in_range": 566 | case "motion_timedout": 567 | // 3 params 568 | parameters = parameters + "name={0}&time={1}&id={2}" 569 | break; 570 | case "door_opened": 571 | case "door_closed": 572 | parameters = parameters + "name={0}&orientchg={1}&x={2}&y={3}&z={4}&id={5}" 573 | break; 574 | case "motion_detected": 575 | // to do, check if PIR type 576 | if (getTagTypeInfo(tag).isPIR == true) { 577 | // pir 578 | parameters = parameters + "name={0}&time={1}&id={2}" 579 | } else { 580 | // standard 581 | parameters = parameters + "name={0}&orientchg={1}&x={2}&y={3}&z={4}&id={5}" 582 | } 583 | break; } 584 | 585 | String callbackString = """{"url":"${getApiServerUrl()}/api/token/${atomicState.accessToken}/smartapps/installations/${app.id}/urlcallback${parameters}","verb":"GET","content":"","disabled":false,"nat":false}""" 586 | return callbackString 587 | } 588 | 589 | def getQuoted(def orig) { return (orig != null) ? "\"${orig}\"": orig } 590 | 591 | def useExitingCallback(Map callback) { 592 | String callbackString = """{"url":"${callback.url}","verb":${getQuoted(callback.verb)},"content":${getQuoted(callback.content)},"disabled":${callback.disabled},"nat":${callback.nat}}""" 593 | return callbackString 594 | } 595 | 596 | def setupCallbacks(def child, def tag) { 597 | def id = getTagID(child.device.deviceNetworkId) 598 | 599 | if (id != null) { 600 | Map query = [ 601 | "id": id 602 | ] 603 | def respMap = postMessage("/ethClient.asmx/LoadEventURLConfig", query) 604 | 605 | if (respMap.d != null) { 606 | 607 | String message = """{"id":${id}, 608 | "config":{ 609 | "__type":"MyTagList.EventURLConfig", 610 | "oor":${setSingleCallback(tag, respMap.d?.oor, "oor")}, 611 | "back_in_range":${setSingleCallback(tag, respMap.d?.back_in_range, "back_in_range")}, 612 | "low_battery":${useExitingCallback(respMap.d?.low_battery)}, 613 | "motion_detected":${setSingleCallback(tag, respMap.d?.motion_detected, "motion_detected")}, 614 | "door_opened":${setSingleCallback(tag, respMap.d?.door_opened, "door_opened")}, 615 | "door_closed":${setSingleCallback(tag, respMap.d?.door_closed, "door_closed")}, 616 | "door_open_toolong":${useExitingCallback(respMap.d?.door_open_toolong)}, 617 | "temp_toohigh":${useExitingCallback(respMap.d?.temp_toohigh)}, 618 | "temp_toolow":${useExitingCallback(respMap.d?.temp_toolow)}, 619 | "temp_normal":${useExitingCallback(respMap.d?.temp_normal)}, 620 | "cap_normal":${useExitingCallback(respMap.d?.cap_normal)}, 621 | "too_dry":${useExitingCallback(respMap.d?.too_dry)}, 622 | "too_humid":${useExitingCallback(respMap.d?.too_humid)}, 623 | "water_detected":${setSingleCallback(tag, respMap.d?.water_detected, "water_detected")}, 624 | "water_dried":${setSingleCallback(tag, respMap.d?.water_dried, "water_dried")}, 625 | "motion_timedout":${setSingleCallback(tag, respMap.d?.motion_timedout, "motion_timedout")} 626 | }, 627 | "applyAll":false}""" 628 | 629 | postMessage("/ethClient.asmx/SaveEventURLConfig", message) 630 | 631 | } 632 | } 633 | } 634 | 635 | def beep(def child, int len) { 636 | def id = getTagID(child.device.deviceNetworkId) 637 | 638 | if (id != null) { 639 | Map query = [ 640 | "id": id, 641 | "beepDuration": len 642 | ] 643 | postMessage("/ethClient.asmx/Beep", query) 644 | } else { 645 | log.trace "Could not find tag" 646 | } 647 | 648 | return null 649 | } 650 | 651 | def light(def child, def on, def flash) { 652 | def id = getTagID(child.device.deviceNetworkId) 653 | 654 | def command = (on == true) ? "/ethClient.asmx/LightOn" : "/ethClient.asmx/LightOff" 655 | 656 | if (id != null) { 657 | Map query = [ 658 | "id": id, 659 | "flash": flash 660 | ] 661 | postMessage(command, query) 662 | } else { 663 | log.trace "Could not find tag" 664 | } 665 | 666 | return null 667 | } 668 | 669 | def startMotionTimer(def child) { 670 | log.trace "start motion timer" 671 | 672 | if (state.motionTimers == null) { 673 | state.motionTimers = [:] 674 | } 675 | 676 | def delayTime = child.getMotionDecay() 677 | 678 | // don't do less than a minute in this way, once WST has the callback working it will be better 679 | delayTime = (delayTime < 60) ? 60 : delayTime 680 | 681 | state.motionTimers[child.device.deviceNetworkId] = now() + delayTime 682 | 683 | runIn(delayTime, motionTimerHander) 684 | } 685 | 686 | def motionTimerHander() { 687 | def more = 0 688 | def removeList = [] 689 | 690 | state.motionTimers.each { child, time -> 691 | if (time <= now()) { 692 | def tag = getChildDevice(child) 693 | resMotionDetection(tag) 694 | removeList.add(child) 695 | } else { 696 | if ((more == 0) || (more > time)) { 697 | more = time 698 | } 699 | } 700 | } 701 | 702 | if (more != 0) { 703 | log.trace "running again" 704 | more = more + 5 - now() 705 | runIn((more < 60) ? 60 : more, motionTimerHander) 706 | } 707 | 708 | // clean up handled events 709 | removeList.each { 710 | state.motionTimers.remove(it) 711 | } 712 | } 713 | 714 | def resMotionDetection(def child) { 715 | log.trace "turning off motion" 716 | 717 | // now turn off in device 718 | def data = [acceleration: "inactive", motion: "inactive"] 719 | child.generateEvent(data) 720 | 721 | return null 722 | } 723 | 724 | def armMotion(def child) { 725 | def id = getTagID(child.device.deviceNetworkId) 726 | 727 | if (id != null) { 728 | Map query = [ 729 | "id": id, 730 | "door_mode_set_closed": true 731 | ] 732 | postMessage("/ethClient.asmx/Arm", query) 733 | } else { 734 | log.trace "Could not find tag" 735 | } 736 | 737 | return null 738 | } 739 | 740 | def disarmMotion(def child) { 741 | def id = getTagID(child.device.deviceNetworkId) 742 | 743 | if (id != null) { 744 | Map query = [ 745 | "id": id 746 | ] 747 | postMessage("/ethClient.asmx/DisArm", query) 748 | } else { 749 | log.trace "Could not find tag" 750 | } 751 | 752 | return null 753 | } 754 | 755 | 756 | def setMotionMode(def child, def mode, def timeDelay) { 757 | log.trace "setting door to closed" 758 | 759 | def id = getTagID(child.device.deviceNetworkId) 760 | 761 | if (id != null) { 762 | if (mode == "disarmed") { 763 | disarmMotion(child) 764 | } else { 765 | Map query = [ 766 | "id": id 767 | ] 768 | def result = postMessage("/ethClient.asmx/LoadMotionSensorConfig", query) 769 | 770 | if (result?.d) { 771 | 772 | switch (mode) { 773 | case "accel": 774 | result.d.door_mode = false 775 | break 776 | case "door": 777 | result.d.door_mode = true 778 | break 779 | } 780 | 781 | result.d.auto_reset_delay = timeDelay 782 | 783 | String jsonString = toJson(result.d) 784 | jsonString = toJson(result.d).substring(1, toJson(result.d).size()-1) 785 | 786 | String queryString = """{"id":${id}, 787 | "config":{"__type":"MyTagList.MotionSensorConfig",${jsonString}}, 788 | "applyAll":false}""" 789 | 790 | postMessage("/ethClient.asmx/SaveMotionSensorConfig", queryString) 791 | 792 | armMotion(child) 793 | } 794 | } 795 | } else { 796 | log.trace "Could not find tag" 797 | } 798 | 799 | return null 800 | } 801 | 802 | def getTagID(def uuid) { 803 | 804 | return atomicState.tags.find{ it.uuid == uuid}?.slaveId 805 | } 806 | 807 | def getTagUUID(def id) { 808 | 809 | return atomicState.tags.find{ it.slaveId == id}?.uuid 810 | } 811 | 812 | def getTagTypeInfo(def tag) { 813 | Map tagInfo = [:] 814 | 815 | tagInfo.isMsTag = (tag.tagType == 12 || tag.tagType == 13); 816 | tagInfo.isMoistureTag = (tag.tagType == 32 || tag.tagType == 33); 817 | tagInfo.hasBeeper = (tag.tagType == 13 || tag.tagType == 12); 818 | tagInfo.isReed = (tag.tagType == 52 || tag.tagType == 53); 819 | tagInfo.isPIR = (tag.tagType == 72); 820 | tagInfo.isKumostat = (tag.tagType == 62); 821 | tagInfo.isHTU = (tag.tagType == 52 || tag.tagType == 62 || tag.tagType == 72 || tag.tagType == 13); 822 | 823 | return tagInfo 824 | } 825 | 826 | def getTagVersion(def tag) { 827 | if (tag.version1 == 2) { 828 | if (tag.rev == 14) return " (v2.1)"; 829 | else return " (v2.0)"; 830 | } 831 | if (tag.tagType != 12) return ""; 832 | if (tag.rev == 0) return " (v1.1)"; 833 | else if (tag.rev == 1) return ' (v1.2)'; 834 | else if (tag.rev == 11) return " (v1.3)"; 835 | else if (tag.rev == 12) return " (v1.4)"; 836 | else if (tag.rev == 13) return " (v1.5)"; 837 | else return ""; 838 | } 839 | 840 | def convertTagTypeToString(def tag) { 841 | def tagString = "Unknown" 842 | 843 | switch (tag.tagType) { 844 | case 12: 845 | tagString = "MotionSensor" 846 | break; 847 | case 13: 848 | tagString = "MotionHTU" 849 | break; 850 | case 72: 851 | tagString = "PIR" 852 | break; 853 | case 52: 854 | tagString = "ReedHTU" 855 | break; 856 | case 53: 857 | tagString = "Reed" 858 | break; 859 | case 62: 860 | tagString = "Kumostat" 861 | break; 862 | case 32: 863 | case 33: 864 | tagString = "Moisture" 865 | break; 866 | } 867 | 868 | return tagString + getTagVersion(tag) 869 | } 870 | 871 | def toJson(Map m) 872 | { 873 | return new org.codehaus.groovy.grails.web.json.JSONObject(m).toString() 874 | } 875 | 876 | def toQueryString(Map m) 877 | { 878 | return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") 879 | } 880 | 881 | def getSmartThingsClientId() { "67953bd9-8adf-422a-a7f0-5dbf256b9024" } 882 | 883 | def debugEvent(message, displayEvent) { 884 | 885 | def results = [ 886 | name: "appdebug", 887 | descriptionText: message, 888 | displayed: displayEvent 889 | ] 890 | log.debug "Generating AppDebug Event: ${results}" 891 | sendEvent (results) 892 | 893 | } -------------------------------------------------------------------------------- /WirelessTags/WirelessTagsMotion.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Wireless Tag Motion 3 | * 4 | * Copyright 2014 Dave Swanson (swanny) 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | metadata { 17 | definition (name: "Wireless Tag Motion", namespace: "swanny", author: "swanny") { 18 | capability "Presence Sensor" 19 | capability "Acceleration Sensor" 20 | capability "Motion Sensor" 21 | capability "Tone" 22 | capability "Relative Humidity Measurement" 23 | capability "Temperature Measurement" 24 | capability "Signal Strength" 25 | capability "Battery" 26 | capability "Refresh" 27 | capability "Polling" 28 | capability "Switch" 29 | capability "Contact Sensor" 30 | 31 | command "generateEvent" 32 | command "setMotionModeAccel" 33 | command "setMotionModeDoor" 34 | command "setMotionModeDisarm" 35 | command "setDoorClosedPosition" 36 | command "initialSetup" 37 | 38 | attribute "tagType","string" 39 | attribute "motionMode", "string" 40 | } 41 | 42 | simulator { 43 | // TODO: define status and reply messages here 44 | } 45 | 46 | tiles { 47 | standardTile("acceleration", "device.acceleration") { 48 | state("active", label:'${name}', icon:"st.motion.acceleration.active", backgroundColor:"#53a7c0") 49 | state("inactive", label:'${name}', icon:"st.motion.acceleration.inactive", backgroundColor:"#ffffff") 50 | } 51 | standardTile("motion", "device.motion") { 52 | state("active", label:'${name}', icon:"st.motion.motion.active", backgroundColor:"#53a7c0") 53 | state("inactive", label:'${name}', icon:"st.motion.motion.inactive", backgroundColor:"#ffffff") 54 | } 55 | valueTile("temperature", "device.temperature") { 56 | state("temperature", label:'${currentValue}°', 57 | backgroundColors:[ 58 | [value: 31, color: "#153591"], 59 | [value: 44, color: "#1e9cbb"], 60 | [value: 59, color: "#90d2a7"], 61 | [value: 74, color: "#44b621"], 62 | [value: 84, color: "#f1d801"], 63 | [value: 95, color: "#d04e00"], 64 | [value: 96, color: "#bc2323"] 65 | ] 66 | ) 67 | } 68 | valueTile("humidity", "device.humidity", inactiveLabel: false) { 69 | state "humidity", label:'${currentValue}% humidity', unit:"" 70 | } 71 | valueTile("rssi", "device.rssi", inactiveLabel: false, decoration: "flat") { 72 | state "rssi", label:'${currentValue}% signal', unit:"" 73 | } 74 | standardTile("presence", "device.presence", canChangeBackground: true) { 75 | state "present", labelIcon:"st.presence.tile.present", backgroundColor:"#53a7c0" 76 | state "not present", labelIcon:"st.presence.tile.not-present", backgroundColor:"#ffffff" 77 | } 78 | standardTile("beep", "device.beep", decoration: "flat") { 79 | state "beep", label:'', action:"tone.beep", icon:"st.secondary.beep", backgroundColor:"#ffffff" 80 | } 81 | valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false) { 82 | state "battery", label:'${currentValue}% battery', unit:"V" 83 | } 84 | standardTile("refresh", "device.temperature", inactiveLabel: false, decoration: "flat") { 85 | state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" 86 | } 87 | standardTile("button", "device.switch") { 88 | state "off", label: 'Off', action: "switch.on", icon: "st.switches.light.off", backgroundColor: "#ffffff", nextState: "on" 89 | state "on", label: 'On', action: "switch.off", icon: "st.switches.light.on", backgroundColor: "#79b821", nextState: "off" 90 | } 91 | valueTile("type", "device.tagType", decoration: "flat") { 92 | state "default", label:'${currentValue}' 93 | } 94 | standardTile("contact", "device.contact") { 95 | state("open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#ffa81e") 96 | state("closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#79b821") 97 | } 98 | valueTile("doorClosed", "device.motionMode", inactiveLabel: false, decoration: "flat") { 99 | state ("accel", label:'Motion Mode: ${currentValue}', action:"setMotionModeAccel", nextState: "door") 100 | state ("door", label:'Motion Mode: ${currentValue}', action:"setMotionModeDoor", nextState: "disarmed") 101 | state ("disarmed", label:'Motion Mode: ${currentValue}', action:"setMotionModeDisarm", nextState: "accel") 102 | } 103 | valueTile("setdoorclosed", "device.temperature", inactiveLabel: false, decoration: "flat") { 104 | state "default", label:'Arm & Set Door Closed Position', action:"setDoorClosedPosition", nextState: "default" 105 | } 106 | main(["temperature", "acceleration", "motion", "presence", "humidity", "contact"]) 107 | details(["temperature", "presence", "humidity", "acceleration", "motion", "contact", "button", "refresh", "type", "doorClosed", "setdoorclosed", "beep", "rssi", "battery"]) 108 | } 109 | 110 | preferences { 111 | input "motionDecay", "number", title: "Motion Rearm Time", description: "Seconds (min 60 for now)", defaultValue: 60, required: true, displayDuringSetup: true 112 | } 113 | } 114 | 115 | // parse events into attributes 116 | def parse(String description) { 117 | log.debug "Parsing '${description}'" 118 | } 119 | 120 | // handle commands 121 | def beep() { 122 | log.debug "Executing 'beep'" 123 | parent.beep(this, 3) 124 | } 125 | 126 | def on() { 127 | log.debug "Executing 'on'" 128 | parent.light(this, true, false) 129 | sendEvent(name: "switch", value: "on") 130 | } 131 | 132 | def off() { 133 | log.debug "Executing 'off'" 134 | parent.light(this, false, false) 135 | sendEvent(name: "switch", value: "off") 136 | } 137 | 138 | void poll() { 139 | log.debug "poll" 140 | parent.pollChild(this) 141 | } 142 | 143 | def refresh() { 144 | log.debug "refresh" 145 | parent.refreshChild(this) 146 | } 147 | 148 | def setMotionModeAccel() { 149 | log.debug "set to door" 150 | def newMode = "door" 151 | parent.setMotionMode(this, newMode, getMotionDecay()) 152 | sendEvent(name: "motionMode", value: newMode) 153 | } 154 | 155 | def setMotionModeDoor() { 156 | log.debug "set to disarm" 157 | def newMode = "disarmed" 158 | parent.setMotionMode(this, newMode, getMotionDecay()) 159 | sendEvent(name: "motionMode", value: newMode) 160 | } 161 | 162 | def setMotionModeDisarm() { 163 | log.debug "set to accel" 164 | def newMode = "accel" 165 | parent.setMotionMode(this, newMode, getMotionDecay()) 166 | sendEvent(name: "motionMode", value: newMode) 167 | } 168 | 169 | def setDoorClosedPosition() { 170 | log.debug "set door closed pos" 171 | parent.disarmMotion(this) 172 | parent.armMotion(this) 173 | } 174 | 175 | def initialSetup() { 176 | sendEvent(name: "motionMode", value: "accel") 177 | parent.setMotionMode(this, "accel", getMotionDecay()) 178 | } 179 | 180 | def getMotionDecay() { 181 | def timer = (settings.motionDecay != null) ? settings.motionDecay.toInteger() : 60 182 | return timer 183 | } 184 | 185 | def updated() { 186 | log.trace "updated" 187 | parent.setMotionMode(this, device.currentState("motionMode")?.stringValue, getMotionDecay()) 188 | } 189 | 190 | void generateEvent(Map results) 191 | { 192 | log.debug "parsing data $results" 193 | 194 | if(results) 195 | { 196 | results.each { name, value -> 197 | def isDisplayed = true 198 | 199 | if (name=="temperature") { 200 | def tempValue = getTemperature(value) 201 | def isChange = isTemperatureStateChange(device, name, tempValue.toString()) 202 | isDisplayed = isChange 203 | 204 | sendEvent(name: name, value: tempValue, unit: getTemperatureScale(), displayed: isDisplayed) 205 | } 206 | else { 207 | def isChange = isStateChange(device, name, value.toString()) 208 | isDisplayed = isChange 209 | 210 | sendEvent(name: name, value: value, isStateChange: isChange, displayed: isDisplayed) 211 | } 212 | } 213 | } 214 | } 215 | 216 | def getTemperature(value) { 217 | def celsius = value 218 | if(getTemperatureScale() == "C"){ 219 | return celsius 220 | } else { 221 | return celsiusToFahrenheit(celsius) as Integer 222 | } 223 | } 224 | 225 | -------------------------------------------------------------------------------- /WirelessTags/WirelessTagsWater.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Wireless Tag Water 3 | * 4 | * Copyright 2014 Dave Swanson (swanny) 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 7 | * in compliance with the License. You may obtain a copy of the License at: 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 12 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 13 | * for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | metadata { 17 | definition (name: "Wireless Tag Water", namespace: "swanny", author: "swanny") { 18 | capability "Water Sensor" 19 | capability "Presence Sensor" 20 | capability "Relative Humidity Measurement" 21 | capability "Temperature Measurement" 22 | capability "Signal Strength" 23 | capability "Battery" 24 | capability "Refresh" 25 | capability "Polling" 26 | 27 | command "generateEvent" 28 | command "initialSetup" 29 | 30 | attribute "tagType","string" 31 | } 32 | 33 | simulator { 34 | // TODO: define status and reply messages here 35 | } 36 | 37 | tiles { 38 | valueTile("temperature", "device.temperature") { 39 | state("temperature", label:'${currentValue}°', 40 | backgroundColors:[ 41 | [value: 31, color: "#153591"], 42 | [value: 44, color: "#1e9cbb"], 43 | [value: 59, color: "#90d2a7"], 44 | [value: 74, color: "#44b621"], 45 | [value: 84, color: "#f1d801"], 46 | [value: 95, color: "#d04e00"], 47 | [value: 96, color: "#bc2323"] 48 | ] 49 | ) 50 | } 51 | valueTile("humidity", "device.humidity", inactiveLabel: false) { 52 | state "humidity", label:'${currentValue}% humidity', unit:"" 53 | } 54 | valueTile("rssi", "device.rssi", inactiveLabel: false, decoration: "flat") { 55 | state "rssi", label:'${currentValue}% signal', unit:"" 56 | } 57 | standardTile("presence", "device.presence", canChangeBackground: true) { 58 | state "present", labelIcon:"st.presence.tile.present", backgroundColor:"#53a7c0" 59 | state "not present", labelIcon:"st.presence.tile.not-present", backgroundColor:"#ffffff" 60 | } 61 | valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false) { 62 | state "battery", label:'${currentValue}% battery', unit:"V" 63 | } 64 | standardTile("refresh", "device.temperature", inactiveLabel: false, decoration: "flat") { 65 | state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" 66 | } 67 | valueTile("type", "device.tagType", decoration: "flat") { 68 | state "default", label:'${currentValue}' 69 | } 70 | standardTile("water", "device.water") { 71 | state "dry", icon:"st.alarm.water.dry", backgroundColor:"#ffffff" 72 | state "wet", icon:"st.alarm.water.wet", backgroundColor:"#53a7c0" 73 | } 74 | main(["water", "temperature", "presence", "humidity"]) 75 | details(["water", "temperature", "humidity", "presence", "refresh", "type", "rssi", "battery"]) 76 | } 77 | } 78 | 79 | // parse events into attributes 80 | def parse(String description) { 81 | log.debug "Parsing '${description}'" 82 | } 83 | 84 | void poll() { 85 | log.debug "poll" 86 | parent.pollChild(this) 87 | } 88 | 89 | def refresh() { 90 | log.debug "refresh" 91 | parent.refreshChild(this) 92 | } 93 | 94 | def initialSetup() { 95 | 96 | } 97 | 98 | def updated() { 99 | log.trace "updated" 100 | } 101 | 102 | void generateEvent(Map results) 103 | { 104 | log.debug "parsing data $results" 105 | 106 | if(results) 107 | { 108 | results.each { name, value -> 109 | def isDisplayed = true 110 | 111 | if (name=="temperature") { 112 | def tempValue = getTemperature(value) 113 | def isChange = isTemperatureStateChange(device, name, tempValue.toString()) 114 | isDisplayed = isChange 115 | 116 | sendEvent(name: name, value: tempValue, unit: getTemperatureScale(), displayed: isDisplayed) 117 | } 118 | else { 119 | def isChange = isStateChange(device, name, value.toString()) 120 | isDisplayed = isChange 121 | 122 | sendEvent(name: name, value: value, isStateChange: isChange, displayed: isDisplayed) 123 | } 124 | } 125 | } 126 | } 127 | 128 | def getTemperature(value) { 129 | def celsius = value 130 | if(getTemperatureScale() == "C"){ 131 | return celsius 132 | } else { 133 | return celsiusToFahrenheit(celsius) as Integer 134 | } 135 | } 136 | 137 | -------------------------------------------------------------------------------- /WirelessTags/readme.md: -------------------------------------------------------------------------------- 1 | ## Features 2 | 3 | The Wireless Sensor Tags integration connects securely to your www.mytaglist.com account securely using OAuth2. Once attached, the SmartApp gets a list of the tags you have in your account. You can then choose which tags you want to create as SmartThings devices. The SmartApp currently creates the devices as a Motion Device Type or a Water Device Type. 4 | 5 | Motion Device Features: 6 | 7 | * Temperature 8 | * Presence (out of range) 9 | * Humidity (shows as zero on tags that don't support humidity) 10 | * Movement (Acceleration) or Open/Close - you choose which mode inside the app 11 | * Set Door Closed position 12 | * Light on/off (using the device switch) 13 | * Beep 14 | * Refresh & Poll 15 | * Signal Strength 16 | * Battery Level 17 | 18 | Water devices support (untested): 19 | 20 | * Wet/Dry 21 | * Humidity 22 | * Temperature 23 | * Presence (out of range) 24 | * Refresh & Poll 25 | * Signal Strength 26 | * Battery Level 27 | 28 | Other tag types will be created as Motion Devices and should support whatever functionality they have in common with the motion tags. 29 | 30 | The majority of the tuning you can do on www.mytaglist.com will apply while you have the tags enabled as part of ST. Tuning options like responsiveness, sensitivity, and threshold angle may improve responsiveness or fuctionality in ST as well. Note that the ST integration overwrites many of the option in the "URL Calling..." dialog and arms/disarms the tags. 31 | 32 | ## Installation 33 | 34 | To set up the Wireless Sensor Tags integration, follow these steps: 35 | 36 | Set up the SmartApp: 37 | * Create a new SmartApp 38 | * Enter any values in the required * fields 39 | * Click the button "Enable OAuth in Smart App" 40 | * Create the Smart App 41 | * Copy the code from WirelessTagsConnect.groovy over the code of your SmartApp 42 | * Save and Publish For Me 43 | 44 | Set up the Device Types: 45 | * Create a new Device Type 46 | * Enter any values in the required * fields 47 | * Create the Device Type 48 | * Copy the code from WirelessTagsMotion.groovy over the code of your Device Type 49 | * Save and Publish For Me 50 | * Repeat these steps for WirelessTagsWater.groovy if you have a Water/Moisture Sensor 51 | 52 | Connect to your Wireless Tags account: 53 | * Open the SmartThings application on your iPhone or Android device 54 | * Go to the Convenience apps section and choose Wireless Tags (Connect) 55 | * If you don't see it in the Convenience section, kill the SmartThings application and restart it 56 | * Follow the instructions in the App for the rest of the details 57 | 58 | If you add new tags to your web account, go through the Wireless Tags (Connect) app again to add the new devices. 59 | 60 | ## Update Previous Installation 61 | Update the SmartApp: 62 | * Open your previously created Smart App 63 | * Copy the code from WirelessTagsConnect.groovy over the code of your SmartApp 64 | * Save and Publish For Me 65 | 66 | Set up the Device Types: 67 | * Open your previously created Device Type 68 | * Copy the code from WirelessTagsMotion.groovy over the code of your Device Type 69 | * Save and Publish For Me 70 | * Repeat these steps for WirelessTagsWater.groovy if you have a Water/Moisture Sensor 71 | 72 | Connect to your Wireless Tags account: 73 | * Open the SmartThings application on your iPhone or Android device 74 | * Go to the Convenience apps section and choose Wireless Tags (Connect) 75 | * Run through the normal setup process even if you don't want to add any new tags. This will update your existing Devices and update the functionality of the SmartApp. --------------------------------------------------------------------------------