diff --git a/devicetypes/roomieremote-agent/simple-sync.src/simple-sync.groovy b/devicetypes/roomieremote-agent/simple-sync.src/simple-sync.groovy new file mode 100644 index 0000000..b54321a --- /dev/null +++ b/devicetypes/roomieremote-agent/simple-sync.src/simple-sync.groovy @@ -0,0 +1,128 @@ +/** + * Simple Sync + * + * Copyright 2015 Roomie Remote, Inc. + * + * Date: 2015-09-22 + */ +metadata +{ + definition (name: "Simple Sync", namespace: "roomieremote-agent", author: "Roomie Remote, Inc.") + { + capability "Media Controller" + } + + // simulator metadata + simulator + { + } + + // UI tile definitions + tiles + { + standardTile("mainTile", "device.status", width: 1, height: 1, icon: "st.Entertainment.entertainment11") + { + state "default", label: "Simple Sync", icon: "st.Home.home2", backgroundColor: "#55A7FF" + } + + def detailTiles = ["mainTile"] + + main "mainTile" + details(detailTiles) + } +} + +def parse(String description) +{ + def results = [] + + try + { + def msg = parseLanMessage(description) + + if (msg.headers && msg.body) + { + switch (msg.headers["X-Roomie-Echo"]) + { + case "getAllActivities": + handleGetAllActivitiesResponse(msg) + break + } + } + } + catch (Throwable t) + { + sendEvent(name: "parseError", value: "$t", description: description) + throw t + } + + results +} + +def handleGetAllActivitiesResponse(response) +{ + def body = parseJson(response.body) + + if (body.status == "success") + { + def json = new groovy.json.JsonBuilder() + def root = json activities: body.data + def data = json.toString() + + sendEvent(name: "activities", value: data) + } +} + +def getAllActivities(evt) +{ + def host = getHostAddress(device.deviceNetworkId) + + def action = new physicalgraph.device.HubAction(method: "GET", + path: "/api/v1/activities", + headers: [HOST: host, "X-Roomie-Echo": "getAllActivities"]) + + action +} + +def startActivity(evt) +{ + def uuid = evt + def host = getHostAddress(device.deviceNetworkId) + def activity = new groovy.json.JsonSlurper().parseText(device.currentValue('activities') ?: "{ 'activities' : [] }").activities.find { it.uuid == uuid } + def toggle = activity["toggle"] + def jsonMap = ["activity_uuid": uuid] + + if (toggle != null) + { + jsonMap << ["toggle_state": toggle ? "on" : "off"] + } + + def json = new groovy.json.JsonBuilder(jsonMap) + def jsonBody = json.toString() + def headers = [HOST: host, "Content-Type": "application/json"] + + def action = new physicalgraph.device.HubAction(method: "POST", + path: "/api/v1/runactivity", + body: jsonBody, + headers: headers) + + action +} + +def getHostAddress(d) +{ + def parts = d.split(":") + def ip = convertHexToIP(parts[0]) + def port = convertHexToInt(parts[1]) + return ip + ":" + port +} + +def String convertHexToIP(hex) +{ + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +def Integer convertHexToInt(hex) +{ + Integer.parseInt(hex,16) +} diff --git a/smartapps/roomieremote-raconnect/simple-sync-connect.src/simple-sync-connect.groovy b/smartapps/roomieremote-raconnect/simple-sync-connect.src/simple-sync-connect.groovy new file mode 100644 index 0000000..44edc2f --- /dev/null +++ b/smartapps/roomieremote-raconnect/simple-sync-connect.src/simple-sync-connect.groovy @@ -0,0 +1,383 @@ +/** + * Simple Sync Connect + * + * Copyright 2015 Roomie Remote, Inc. + * + * Date: 2015-09-22 + */ + +definition( + name: "Simple Sync Connect", + namespace: "roomieremote-raconnect", + author: "Roomie Remote, Inc.", + description: "Integrate SmartThings with your Simple Control activities via Simple Sync.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-60.png", + iconX2Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png", + iconX3Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png") + +preferences() +{ + page(name: "mainPage", title: "Simple Sync Setup", content: "mainPage", refreshTimeout: 5) + page(name:"agentDiscovery", title:"Simple Sync Discovery", content:"agentDiscovery", refreshTimeout:5) + page(name:"manualAgentEntry") + page(name:"verifyManualEntry") +} + +def mainPage() +{ + if (canInstallLabs()) + { + return agentDiscovery() + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"mainPage", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") + { + paragraph "$upgradeNeeded" + } + } + } +} + +def agentDiscovery(params=[:]) +{ + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = refreshCount == 0 ? 2 : 5 + + if (!state.subscribe) + { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //ssdp request every fifth refresh + if ((refreshCount % 5) == 0) + { + discoverAgents() + } + + def agentsDiscovered = agentsDiscovered() + + return dynamicPage(name:"agentDiscovery", title:"Pair with Simple Sync", nextPage:"", refreshInterval: refreshInterval, install:true, uninstall: true) { + section("Pair with Simple Sync") + { + input "selectedAgent", "enum", required:true, title:"Select Simple Sync\n(${agentsDiscovered.size() ?: 0} found)", multiple:false, options:agentsDiscovered + href(name:"manualAgentEntry", + title:"Manually Configure Simple Sync", + required:false, + page:"manualAgentEntry") + } + } +} + +def manualAgentEntry() +{ + dynamicPage(name:"manualAgentEntry", title:"Manually Configure Simple Sync", nextPage:"verifyManualEntry", install:false, uninstall:true) { + section("Manually Configure Simple Sync") + { + paragraph "In the event that Simple Sync cannot be automatically discovered by your SmartThings hub, you may enter Simple Sync's IP address here." + input(name: "manualIPAddress", type: "text", title: "IP Address", required: true) + } + } +} + +def verifyManualEntry() +{ + def hexIP = convertIPToHexString(manualIPAddress) + def hexPort = convertToHexString(47147) + def uuid = "593C03D2-1DA9-4CDB-A335-6C6DC98E56C3" + def hubId = "" + + for (hub in location.hubs) + { + if (hub.localIP != null) + { + hubId = hub.id + break + } + } + + def manualAgent = [deviceType: "04", + mac: "unknown", + ip: hexIP, + port: hexPort, + ssdpPath: "/upnp/Roomie.xml", + ssdpUSN: "uuid:$uuid::urn:roomieremote-com:device:roomie:1", + hub: hubId, + verified: true, + name: "Simple Sync $manualIPAddress"] + + state.agents[uuid] = manualAgent + + addOrUpdateAgent(state.agents[uuid]) + + dynamicPage(name: "verifyManualEntry", title: "Manual Configuration Complete", nextPage: "", install:true, uninstall:true) { + section("") + { + paragraph("Tap Done to complete the installation process.") + } + } +} + +def discoverAgents() +{ + def urn = getURN() + + sendHubCommand(new physicalgraph.device.HubAction("lan discovery $urn", physicalgraph.device.Protocol.LAN)) +} + +def agentsDiscovered() +{ + def gAgents = getAgents() + def agents = gAgents.findAll { it?.value?.verified == true } + def map = [:] + agents.each + { + map["${it.value.uuid}"] = it.value.name + } + map +} + +def getAgents() +{ + if (!state.agents) + { + state.agents = [:] + } + + state.agents +} + +def installed() +{ + initialize() +} + +def updated() +{ + initialize() +} + +def initialize() +{ + if (state.subscribe) + { + unsubscribe() + state.subscribe = false + } + + if (selectedAgent) + { + addOrUpdateAgent(state.agents[selectedAgent]) + } +} + +def addOrUpdateAgent(agent) +{ + def children = getChildDevices() + def dni = agent.ip + ":" + agent.port + def found = false + + children.each + { + if ((it.getDeviceDataByName("mac") == agent.mac)) + { + found = true + + if (it.getDeviceNetworkId() != dni) + { + it.setDeviceNetworkId(dni) + } + } + else if (it.getDeviceNetworkId() == dni) + { + found = true + } + } + + if (!found) + { + addChildDevice("roomieremote-agent", "Simple Sync", dni, agent.hub, [label: "Simple Sync"]) + } +} + +def locationHandler(evt) +{ + def description = evt?.description + def urn = getURN() + def hub = evt?.hubId + def parsedEvent = parseEventMessage(description) + + parsedEvent?.putAt("hub", hub) + + //SSDP DISCOVERY EVENTS + if (parsedEvent?.ssdpTerm?.contains(urn)) + { + def agent = parsedEvent + def ip = convertHexToIP(agent.ip) + def agents = getAgents() + + agent.verified = true + agent.name = "Simple Sync $ip" + + if (!agents[agent.uuid]) + { + state.agents[agent.uuid] = agent + } + } +} + +private def parseEventMessage(String description) +{ + def event = [:] + def parts = description.split(',') + + parts.each + { part -> + part = part.trim() + if (part.startsWith('devicetype:')) + { + def valueString = part.split(":")[1].trim() + event.devicetype = valueString + } + else if (part.startsWith('mac:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.mac = valueString + } + } + else if (part.startsWith('networkAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ip = valueString + } + } + else if (part.startsWith('deviceAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.port = valueString + } + } + else if (part.startsWith('ssdpPath:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ssdpPath = valueString + } + } + else if (part.startsWith('ssdpUSN:')) + { + part -= "ssdpUSN:" + def valueString = part.trim() + if (valueString) + { + event.ssdpUSN = valueString + + def uuid = getUUIDFromUSN(valueString) + + if (uuid) + { + event.uuid = uuid + } + } + } + else if (part.startsWith('ssdpTerm:')) + { + part -= "ssdpTerm:" + def valueString = part.trim() + if (valueString) + { + event.ssdpTerm = valueString + } + } + else if (part.startsWith('headers')) + { + part -= "headers:" + def valueString = part.trim() + if (valueString) + { + event.headers = valueString + } + } + else if (part.startsWith('body')) + { + part -= "body:" + def valueString = part.trim() + if (valueString) + { + event.body = valueString + } + } + } + + event +} + +def getURN() +{ + return "urn:roomieremote-com:device:roomie:1" +} + +def getUUIDFromUSN(usn) +{ + def parts = usn.split(":") + + for (int i = 0; i < parts.size(); ++i) + { + if (parts[i] == "uuid") + { + return parts[i + 1] + } + } +} + +def String convertHexToIP(hex) +{ + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +def Integer convertHexToInt(hex) +{ + Integer.parseInt(hex,16) +} + +def String convertToHexString(n) +{ + String hex = String.format("%X", n.toInteger()) +} + +def String convertIPToHexString(ipString) +{ + String hex = ipString.tokenize(".").collect { + String.format("%02X", it.toInteger()) + }.join() +} + +def Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +def Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +def List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} \ No newline at end of file diff --git a/smartapps/roomieremote-ratrigger/simple-sync-trigger.src/simple-sync-trigger.groovy b/smartapps/roomieremote-ratrigger/simple-sync-trigger.src/simple-sync-trigger.groovy new file mode 100644 index 0000000..3fd4d08 --- /dev/null +++ b/smartapps/roomieremote-ratrigger/simple-sync-trigger.src/simple-sync-trigger.groovy @@ -0,0 +1,296 @@ +/** + * Simple Sync Trigger + * + * Copyright 2015 Roomie Remote, Inc. + * + * Date: 2015-09-22 + */ +definition( + name: "Simple Sync Trigger", + namespace: "roomieremote-ratrigger", + author: "Roomie Remote, Inc.", + description: "Trigger Simple Control activities when certain actions take place in your home.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-60.png", + iconX2Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png", + iconX3Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png") + + +preferences { + page(name: "agentSelection", title: "Select your Simple Sync") + page(name: "refreshActivities", title: "Updating list of Simple Sync activities") + page(name: "control", title: "Run a Simple Control activity when something happens") + page(name: "timeIntervalInput", title: "Only during a certain time", install: true, uninstall: true) { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def agentSelection() +{ + if (agent) + { + state.refreshCount = 0 + } + + dynamicPage(name: "agentSelection", title: "Select your Simple Sync", nextPage: "control", install: false, uninstall: true) { + section { + input "agent", "capability.mediaController", title: "Simple Sync", required: true, multiple: false + } + } +} + +def control() +{ + def activities = agent.latestValue('activities') + + if (!activities || !state.refreshCount) + { + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = refreshCount == 0 ? 2 : 4 + + // Request activities every 5th attempt + if((refreshCount % 5) == 0) + { + agent.getAllActivities() + } + + dynamicPage(name: "control", title: "Updating list of Simple Control activities", nextPage: "", refreshInterval: refreshInterval, install: false, uninstall: true) { + section("") { + paragraph "Retrieving activities from Simple Sync" + } + } + } + else + { + dynamicPage(name: "control", title: "Run a Simple Control activity when something happens", nextPage: "timeIntervalInput", install: false, uninstall: true) { + def anythingSet = anythingSet() + if (anythingSet) { + section("When..."){ + ifSet "motion", "capability.motionSensor", title: "Motion Detected", required: false, multiple: true + ifSet "motionInactive", "capability.motionSensor", title: "Motion Stops", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + section(anythingSet ? "Select additional triggers" : "When...", hideable: anythingSet, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "motionInactive", "capability.motionSensor", title: "Motion Stops", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section("Run this activity"){ + input "activity", "enum", title: "Activity?", required: true, options: new groovy.json.JsonSlurper().parseText(activities ?: "[]").activities?.collect { ["${it.uuid}": it.name] } + } + + section("More options", hideable: true, hidden: true) { + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)" + } + } + } +} + +private anythingSet() { + for (name in ["motion","motionInactive","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","button1","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + subscribeToEvents() +} + +def updated() { + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace "subscribeToEvents()" + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(motionInactive, "motion.inactive", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt) { + if (allOk) { + def lastTime = state[frequencyKey(evt)] + if (oncePerDayOk(lastTime)) { + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + startActivity(evt) + } + else { + log.debug "Not taking action because $frequency minutes have not elapsed since last action" + } + } + else { + startActivity(evt) + } + } + else { + log.debug "Not taking action because it was already taken today" + } + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + startActivity(evt) +} + +private startActivity(evt) { + agent.startActivity(activity) + + if (frequency) { + state.lastActionTimeStamp = now() + } +} + +private frequencyKey(evt) { + //evt.deviceId ?: evt.value + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = true + if (oncePerDay) { + result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + } + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +private getDaysOk() { + def result = true + if (days) { + def df = new java.text.SimpleDateFormat("EEEE") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + log.trace "daysOk = $result" + result +} + +private getTimeOk() { + def result = true + if (starting && ending) { + def currTime = now() + def start = timeToday(starting).time + def stop = timeToday(ending).time + result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start + } + log.trace "timeOk = $result" + result +} + +private hhmm(time, fmt = "h:mm a") +{ + def t = timeToday(time, location.timeZone) + def f = new java.text.SimpleDateFormat(fmt) + f.setTimeZone(location.timeZone ?: timeZone(time)) + f.format(t) +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} \ No newline at end of file diff --git a/smartapps/roomieremote-roomieconnect/simple-control.src/simple-control.groovy b/smartapps/roomieremote-roomieconnect/simple-control.src/simple-control.groovy new file mode 100644 index 0000000..08fb74e --- /dev/null +++ b/smartapps/roomieremote-roomieconnect/simple-control.src/simple-control.groovy @@ -0,0 +1,703 @@ +/** + * Simple Control + * + * Copyright 2015 Roomie Remote, Inc. + * + * Date: 2016-01-11 + */ + +definition( + name: "Simple Control", + namespace: "roomieremote-roomieconnect", + author: "Roomie Remote, Inc.", + description: "Integrate SmartThings with your Simple Control activities.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-60.png", + iconX2Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png", + iconX3Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png") + +preferences() +{ + section("Allow Simple Control to Monitor and Control These Things...") { + + input "switches", "capability.switch", title: "Which Switches?", multiple: true, required: false + input "motionSensors", "capability.motionSensor", title: "Which Motion Sensors?", multiple: true, required: false + input "presenceSensors", "capability.presenceSensor", title: "Which Presence Sensors?", multiple: true, required: false + input "contactSensors", "capability.contactSensor", title: "Which Contact Sensors?", multiple: true, required: false + input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false + input "beacons", "capability.beacon", title: "Which Beacons?", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Which Thermostats?", multiple: true, required: false + input "imageCaptures", "capability.imageCapture", title: "Which Image Capture Devices?", multiple: true, required: false + input "relaySwitches", "capability.relaySwitch", title: "Which Relay Switches?", multiple: true, required: false + input "doorControls", "capability.doorControl", title: "Which Door Controls?", multiple: true, required: false + input "colorControls", "capability.colorControl", title: "Which Color Controllers?", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Which Music Players?", multiple: true, required: false + input "speechSynthesizers", "capability.speecySynthesis", title: "Which Speakable Devices?", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Which Adjustable Switches?", multiple: true, required: false + input "indicators", "capability.indicator", title: "Which Indicators?", multiple: true, required: false + input "mediaControllers", "capability.mediaController", title: "Which Media Controllers?", multiple: true, required: false + input "tones", "capability.tone", title: "Which Tone Emitters?", multiple: true, required: false + input "tvs", "capability.tv", title: "Which Televisions?", multiple: true, required: false + input "alarms", "capability.alarm", title: "Which Sirens?", multiple: true, required: false + input "valves", "capability.valve", title: "Which Valves", multiple: true, required: false + input "pushButtons", "capability.button", title: "Which Buttons?", multiple: true, required: false + input "smokeDetectors", "capability.smokeDetector", title: "Which Smoke Detectors?", multiple: true, required: false + input "coDetectors", "capability.carbonMonoxideDetector", title: "Which Carbon Monoxide Detectors?", multiple: true, required: false + input "accelerationSensors", "capability.accelerationSensor", title: "Which Vibration Sensors?", multiple: true, required: false + input "energyMeters", "capability.energyMeter", title: "Which Energy Meters?", multiple: true, required: false + input "powerMeters", "capability.powerMeter", title: "Which Power Meters?", multiple: true, required: false + input "lightSensors", "capability.illuminanceMeasurement", title: "Which Light Sensors?", multiple: true, required: false + input "humiditySensors", "capability.relativeHumidityMeasurement", title: "Which Relative Humidity Sensors?", multiple: true, required: false + input "temperatureSensors", "capability.temperatureMeasurement", title: "Which Temperature Sensors?", multiple: true, required: false + input "speechRecognizers", "capability.speechRecognition", title: "Which Speech Recognizers?", multiple: true, required: false + input "stepSensors", "capability.stepSensor", title: "Which Step Sensors?", multiple: true, required: false + input "touchSensors", "capability.touchSensor", title: "Which Touch Sensors?", multiple: true, required: false + + } + + page(name: "mainPage", title: "Simple Control Setup", content: "mainPage", refreshTimeout: 5) + page(name:"agentDiscovery", title:"Simple Sync Discovery", content:"agentDiscovery", refreshTimeout:5) + page(name:"manualAgentEntry") + page(name:"verifyManualEntry") +} + +mappings { + path("/devices") { + action: [ + GET: "getDevices", + POST: "handleDevicesWithIDs" + ] + } + path("/device/:id") { + action: [ + GET: "getDevice", + POST: "updateDevice" + ] + } + path("/subscriptions") { + action: [ + GET: "listSubscriptions", + POST: "addSubscription", // {"deviceId":"xxx", "attributeName":"xxx","callbackUrl":"http://..."} + DELETE: "removeAllSubscriptions" + ] + } + path("/subscriptions/:id") { + action: [ + DELETE: "removeSubscription" + ] + } +} + +private getAllDevices() +{ + //log.debug("getAllDevices()") + ([] + switches + locks + thermostats + imageCaptures + relaySwitches + doorControls + colorControls + musicPlayers + speechSynthesizers + switchLevels + indicators + mediaControllers + tones + tvs + alarms + valves + motionSensors + presenceSensors + beacons + pushButtons + smokeDetectors + coDetectors + contactSensors + accelerationSensors + energyMeters + powerMeters + lightSensors + humiditySensors + temperatureSensors + speechRecognizers + stepSensors + touchSensors)?.findAll()?.unique { it.id } +} + +def getDevices() +{ + //log.debug("getDevices, params: ${params}") + allDevices.collect { + //log.debug("device: ${it}") + deviceItem(it) + } +} + +def getDevice() +{ + //log.debug("getDevice, params: ${params}") + def device = allDevices.find { it.id == params.id } + if (!device) + { + render status: 404, data: '{"msg": "Device not found"}' + } + else + { + deviceItem(device) + } +} + +def handleDevicesWithIDs() +{ + //log.debug("handleDevicesWithIDs, params: ${params}") + def data = request.JSON + def ids = data?.ids?.findAll()?.unique() + //log.debug("ids: ${ids}") + def command = data?.command + def arguments = data?.arguments + if (command) + { + def success = false + //log.debug("command ${command}, arguments ${arguments}") + for (devId in ids) + { + def device = allDevices.find { it.id == devId } + if (device) { + if (arguments) { + device."$command"(*arguments) + } else { + device."$command"() + } + success = true + } else { + //log.debug("device not found ${devId}") + } + } + + if (success) + { + render status: 200, data: "{}" + } + else + { + render status: 404, data: '{"msg": "Device not found"}' + } + } + else + { + ids.collect { + def currentId = it + def device = allDevices.find { it.id == currentId } + if (device) + { + deviceItem(device) + } + } + } +} + +private deviceItem(device) { + [ + id: device.id, + label: device.displayName, + currentState: device.currentStates, + capabilities: device.capabilities?.collect {[ + name: it.name + ]}, + attributes: device.supportedAttributes?.collect {[ + name: it.name, + dataType: it.dataType, + values: it.values + ]}, + commands: device.supportedCommands?.collect {[ + name: it.name, + arguments: it.arguments + ]}, + type: [ + name: device.typeName, + author: device.typeAuthor + ] + ] +} + +def updateDevice() +{ + //log.debug("updateDevice, params: ${params}") + def data = request.JSON + def command = data?.command + def arguments = data?.arguments + + //log.debug("updateDevice, params: ${params}, request: ${data}") + if (!command) { + render status: 400, data: '{"msg": "command is required"}' + } else { + def device = allDevices.find { it.id == params.id } + if (device) { + if (arguments) { + device."$command"(*arguments) + } else { + device."$command"() + } + render status: 204, data: "{}" + } else { + render status: 404, data: '{"msg": "Device not found"}' + } + } +} + +def listSubscriptions() +{ + //log.debug "listSubscriptions()" + app.subscriptions?.findAll { it.deviceId }?.collect { + def deviceInfo = state[it.deviceId] + def response = [ + id: it.id, + deviceId: it.deviceId, + attributeName: it.data, + handler: it.handler + ] + //if (!selectedAgent) { + response.callbackUrl = deviceInfo?.callbackUrl + //} + response + } ?: [] +} + +def addSubscription() { + def data = request.JSON + def attribute = data.attributeName + def callbackUrl = data.callbackUrl + + //log.debug "addSubscription, params: ${params}, request: ${data}" + if (!attribute) { + render status: 400, data: '{"msg": "attributeName is required"}' + } else { + def device = allDevices.find { it.id == data.deviceId } + if (device) { + //if (!selectedAgent) { + //log.debug "Adding callbackUrl: $callbackUrl" + state[device.id] = [callbackUrl: callbackUrl] + //} + //log.debug "Adding subscription" + def subscription = subscribe(device, attribute, deviceHandler) + if (!subscription || !subscription.eventSubscription) { + //log.debug("subscriptions: ${app.subscriptions}") + //for (sub in app.subscriptions) + //{ + //log.debug("subscription.id ${sub.id} subscription.handler ${sub.handler} subscription.deviceId ${sub.deviceId}") + //log.debug(sub.properties.collect{it}.join('\n')) + //} + subscription = app.subscriptions?.find { it.device.id == data.deviceId && it.data == attribute && it.handler == 'deviceHandler' } + } + + def response = [ + id: subscription.id, + deviceId: subscription.device?.id, + attributeName: subscription.data, + handler: subscription.handler + ] + //if (!selectedAgent) { + response.callbackUrl = callbackUrl + //} + response + } else { + render status: 400, data: '{"msg": "Device not found"}' + } + } +} + +def removeSubscription() +{ + def subscription = app.subscriptions?.find { it.id == params.id } + def device = subscription?.device + + //log.debug "removeSubscription, params: ${params}, subscription: ${subscription}, device: ${device}" + if (device) { + //log.debug "Removing subscription for device: ${device.id}" + state.remove(device.id) + unsubscribe(device) + } + render status: 204, data: "{}" +} + +def removeAllSubscriptions() +{ + for (sub in app.subscriptions) + { + //log.debug("Subscription: ${sub}") + //log.debug(sub.properties.collect{it}.join('\n')) + def handler = sub.handler + def device = sub.device + + if (device && handler == 'deviceHandler') + { + //log.debug(device.properties.collect{it}.join('\n')) + //log.debug("Removing subscription for device: ${device}") + state.remove(device.id) + unsubscribe(device) + } + } +} + +def deviceHandler(evt) { + def deviceInfo = state[evt.deviceId] + //if (selectedAgent) { + // sendToRoomie(evt, agentCallbackUrl) + //} else if (deviceInfo) { + if (deviceInfo) + { + if (deviceInfo.callbackUrl) { + sendToRoomie(evt, deviceInfo.callbackUrl) + } else { + log.warn "No callbackUrl set for device: ${evt.deviceId}" + } + } else { + log.warn "No subscribed device found for device: ${evt.deviceId}" + } +} + +def sendToRoomie(evt, String callbackUrl) { + def callback = new URI(callbackUrl) + def host = callback.port != -1 ? "${callback.host}:${callback.port}" : callback.host + def path = callback.query ? "${callback.path}?${callback.query}".toString() : callback.path + sendHubCommand(new physicalgraph.device.HubAction( + method: "POST", + path: path, + headers: [ + "Host": host, + "Content-Type": "application/json" + ], + body: [evt: [deviceId: evt.deviceId, name: evt.name, value: evt.value]] + )) +} + +def mainPage() +{ + if (canInstallLabs()) + { + return agentDiscovery() + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"mainPage", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") + { + paragraph "$upgradeNeeded" + } + } + } +} + +def agentDiscovery(params=[:]) +{ + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = refreshCount == 0 ? 2 : 5 + + if (!state.subscribe) + { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //ssdp request every fifth refresh + if ((refreshCount % 5) == 0) + { + discoverAgents() + } + + def agentsDiscovered = agentsDiscovered() + + return dynamicPage(name:"agentDiscovery", title:"Pair with Simple Sync", nextPage:"", refreshInterval: refreshInterval, install:true, uninstall: true) { + section("Pair with Simple Sync") + { + input "selectedAgent", "enum", required:true, title:"Select Simple Sync\n(${agentsDiscovered.size() ?: 0} found)", multiple:false, options:agentsDiscovered + href(name:"manualAgentEntry", + title:"Manually Configure Simple Sync", + required:false, + page:"manualAgentEntry") + } + } +} + +def manualAgentEntry() +{ + dynamicPage(name:"manualAgentEntry", title:"Manually Configure Simple Sync", nextPage:"verifyManualEntry", install:false, uninstall:true) { + section("Manually Configure Simple Sync") + { + paragraph "In the event that Simple Sync cannot be automatically discovered by your SmartThings hub, you may enter Simple Sync's IP address here." + input(name: "manualIPAddress", type: "text", title: "IP Address", required: true) + } + } +} + +def verifyManualEntry() +{ + def hexIP = convertIPToHexString(manualIPAddress) + def hexPort = convertToHexString(47147) + def uuid = "593C03D2-1DA9-4CDB-A335-6C6DC98E56C3" + def hubId = "" + + for (hub in location.hubs) + { + if (hub.localIP != null) + { + hubId = hub.id + break + } + } + + def manualAgent = [deviceType: "04", + mac: "unknown", + ip: hexIP, + port: hexPort, + ssdpPath: "/upnp/Roomie.xml", + ssdpUSN: "uuid:$uuid::urn:roomieremote-com:device:roomie:1", + hub: hubId, + verified: true, + name: "Simple Sync $manualIPAddress"] + + state.agents[uuid] = manualAgent + + addOrUpdateAgent(state.agents[uuid]) + + dynamicPage(name: "verifyManualEntry", title: "Manual Configuration Complete", nextPage: "", install:true, uninstall:true) { + section("") + { + paragraph("Tap Done to complete the installation process.") + } + } +} + +def discoverAgents() +{ + def urn = getURN() + + sendHubCommand(new physicalgraph.device.HubAction("lan discovery $urn", physicalgraph.device.Protocol.LAN)) +} + +def agentsDiscovered() +{ + def gAgents = getAgents() + def agents = gAgents.findAll { it?.value?.verified == true } + def map = [:] + agents.each + { + map["${it.value.uuid}"] = it.value.name + } + map +} + +def getAgents() +{ + if (!state.agents) + { + state.agents = [:] + } + + state.agents +} + +def installed() +{ + initialize() +} + +def updated() +{ + initialize() +} + +def initialize() +{ + if (state.subscribe) + { + unsubscribe() + state.subscribe = false + } + + if (selectedAgent) + { + addOrUpdateAgent(state.agents[selectedAgent]) + } +} + +def addOrUpdateAgent(agent) +{ + def children = getChildDevices() + def dni = agent.ip + ":" + agent.port + def found = false + + children.each + { + if ((it.getDeviceDataByName("mac") == agent.mac)) + { + found = true + + if (it.getDeviceNetworkId() != dni) + { + it.setDeviceNetworkId(dni) + } + } + else if (it.getDeviceNetworkId() == dni) + { + found = true + } + } + + if (!found) + { + addChildDevice("roomieremote-agent", "Simple Sync", dni, agent.hub, [label: "Simple Sync"]) + } +} + +def locationHandler(evt) +{ + def description = evt?.description + def urn = getURN() + def hub = evt?.hubId + def parsedEvent = parseEventMessage(description) + + parsedEvent?.putAt("hub", hub) + + //SSDP DISCOVERY EVENTS + if (parsedEvent?.ssdpTerm?.contains(urn)) + { + def agent = parsedEvent + def ip = convertHexToIP(agent.ip) + def agents = getAgents() + + agent.verified = true + agent.name = "Simple Sync $ip" + + if (!agents[agent.uuid]) + { + state.agents[agent.uuid] = agent + } + } +} + +private def parseEventMessage(String description) +{ + def event = [:] + def parts = description.split(',') + + parts.each + { part -> + part = part.trim() + if (part.startsWith('devicetype:')) + { + def valueString = part.split(":")[1].trim() + event.devicetype = valueString + } + else if (part.startsWith('mac:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.mac = valueString + } + } + else if (part.startsWith('networkAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ip = valueString + } + } + else if (part.startsWith('deviceAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.port = valueString + } + } + else if (part.startsWith('ssdpPath:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ssdpPath = valueString + } + } + else if (part.startsWith('ssdpUSN:')) + { + part -= "ssdpUSN:" + def valueString = part.trim() + if (valueString) + { + event.ssdpUSN = valueString + + def uuid = getUUIDFromUSN(valueString) + + if (uuid) + { + event.uuid = uuid + } + } + } + else if (part.startsWith('ssdpTerm:')) + { + part -= "ssdpTerm:" + def valueString = part.trim() + if (valueString) + { + event.ssdpTerm = valueString + } + } + else if (part.startsWith('headers')) + { + part -= "headers:" + def valueString = part.trim() + if (valueString) + { + event.headers = valueString + } + } + else if (part.startsWith('body')) + { + part -= "body:" + def valueString = part.trim() + if (valueString) + { + event.body = valueString + } + } + } + + event +} + +def getURN() +{ + return "urn:roomieremote-com:device:roomie:1" +} + +def getUUIDFromUSN(usn) +{ + def parts = usn.split(":") + + for (int i = 0; i < parts.size(); ++i) + { + if (parts[i] == "uuid") + { + return parts[i + 1] + } + } +} + +def String convertHexToIP(hex) +{ + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +def Integer convertHexToInt(hex) +{ + Integer.parseInt(hex,16) +} + +def String convertToHexString(n) +{ + String hex = String.format("%X", n.toInteger()) +} + +def String convertIPToHexString(ipString) +{ + String hex = ipString.tokenize(".").collect { + String.format("%02X", it.toInteger()) + }.join() +} + +def Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +def Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +def List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} + + +