/** * Hubigraph Timeline Child App * * Copyright 2020, but let's behonest, you'll copy it * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * */ // Hubigraph Line Graph Changelog // V 0.1 Intial release // V 0.2 Fixed startup code which needed all three device types, now one will work // V 0.22 Update to support tiles // V 0.3 Loading Update; Removed ALL processing from Hub, uses websocket endpoint // V 0.4 Uses any device // V 0.5 Allows ordering of devices // ****BETA BUILD // v0.1 Added Hubigraph Tile support with Auto-add Dashboard Tile // v0.2 Added Custom Device/Attribute Labels // v0.3 Added waiting screen for initial graph loading & sped up load times // V 1.0 Released (not Beta) Cleanup and Preview Enabled // V 1.5 Ordering, Color and Common API Update // V 1.8 Smoother sliders, bug fixes import groovy.json.JsonOutput def ignoredEvents() { return [ 'lastReceive' , 'reachable' , 'buttonReleased' , 'buttonPressed', 'lastCheckinDate', 'lastCheckin', 'buttonHeld' ] } def version() { return "v0.22" } definition( name: "Hubigraph Time Line", namespace: "tchoward", author: "Thomas Howard", description: "Hubigraph Time Line", category: "", parent: "tchoward:Hubigraphs", iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", ) preferences { section ("test"){ page(name: "mainPage", install: true, uninstall: true) page(name: "deviceSelectionPage", nextPage: "attributeConfigurationPage") page(name: "attributeConfigurationPage", nextPage: "graphSetupPage") page(name: "graphSetupPage", nextPage: "mainPage") page(name: "enableAPIPage") page(name: "disableAPIPage") } mappings { path("/graph/") { action: [ GET: "getGraph" ] } } path("/getData/") { action: [ GET: "getData" ] } path("/getOptions/") { action: [ GET: "getOptions" ] } path("/getSubscriptions/") { action: [ GET: "getSubscriptions" ] } } def getAttributeType(attrib, title){ switch (attrib){ case "motion": return ["motion", "Motion (active/inactive)"]; case "switch": return ["switch", "Switch (on/off)"]; case "contact": return ["contact", "Contact (open/close)"]; case "acceleration": return ["acceleration", "Acceleration (active/inactive)"] case "audioVolume": case "number": return [title, "Number (Choose threshold)"]; } } def call(Closure code) { code.setResolveStrategy(Closure.DELEGATE_ONLY); code.setDelegate(this); code.call(); } def deviceSelectionPage() { dynamicPage(name: "deviceSelectionPage") { parent.hubiForm_section(this, "Device Selection", 1) { input (type: "capability.*", name: "sensors", title: "Choose Sensors", multiple: true, submitOnChange: true) if (sensors) { sensors.each{sensor -> id = sensor.id; sensor_attributes = sensor.getSupportedAttributes().collect { it.getName() }; def container = []; container << parent.hubiForm_sub_section(this, "${sensor.displayName}"); parent.hubiForm_container(this, container, 1); input( type: "enum", name: "attributes_${id}", title: "Attributes to graph", required: true, multiple: true, options: sensor_attributes, defaultValue: "1", submitOnChange: false ) } } } } } def attributeConfigurationPage() { def supportedTypes = [ "alarm": ["start": "on", "end": "off"], "contact": ["start": "open", "end": "closed"], "switch": ["start": "on", "end": "off"], "motion": ["start": "active", "end": "inactive"], "mute": ["start": "muted", "end": "unmuted"], "presence": ["start":"present", "end":"not present"], "holdableButton": ["start":"true", "end":"false"], "carbonMonoxide": ["start":"detected", "end":"clear"], "playing": ["start":"playing", "end":"stopped"], "door": ["start": "open", "end": "closed"], "speed": ["start": "on", "end": "off"], "lock": ["start": "unlocked", "end": "locked"], "shock": ["start": "detected", "end": "clear"], "sleepSensor": ["start": "sleeping", "end": "not sleeping"], "smoke": ["start":"detected", "end":"clear"], "sound": ["start":"detected", "end":"not detected"], "tamper": ["start":"detected", "end":"clear"], "valve": ["start": "open", "end": "closed"], "camera": ["start": "on", "end": "off"], "water": ["start": "wet", "end": "dry"], "windowShade": ["start": "open", "end": "closed"], "acceleration": ["start": "inactive", "end": "active"] ]; state.count_ = 0; dynamicPage(name: "attributeConfigurationPage") { parent.hubiForm_section(this, "Directions", 1, "directions"){ container = []; container << parent.hubiForm_text(this, """Configure what counts as a 'start' or 'end' event for each attribute on the timeline. For example, Switches start when they are 'on' and end when they are 'off'.\n\nSome attributes will automatically populate. You can change them if you have a different configuration (chances are you won't). Additionally, for devices with numeric values, you can define a range of values that count as 'start' or 'end'. For example, to select all the times a temperature is above 70.5 degrees farenheight, you would set the start to '> 70.5', and the end to '< 70.5'. Supported comparitors are: '<', '>', '<=', '>=', '==', '!='.\n\nBecause we are dealing with HTML, '<' is abbreviated to &lt; after you save. That is completely normal. It will still work.""" ); parent.hubiForm_container(this, container, 1); } parent.hubiForm_section(this, "Graph Order", 1, "directions"){ parent.hubiForm_list_reorder(this, "graph_order", "line"); } cnt = 1; sensors.each { sensor -> def attributes = settings["attributes_${sensor.id}"]; attributes.each { attribute -> state.count_++; parent.hubiForm_section(this, "${sensor.displayName} ${attribute}", 1, "directions"){ container = []; container << parent.hubiForm_text_input(this, "Override Device Name
Use %deviceName% for DEVICE and %attributeName% for ATTRIBUTE
", "graph_name_override_${sensor.id}_${attribute}", "%deviceName%: %attributeName%", false); container << parent.hubiForm_color (this, "Line", "attribute_${sensor.id}_${attribute}_line", "#3e4475", false, true); container << parent.hubiForm_text_input (this, "Start event value", "attribute_${sensor.id}_${attribute}_start", supportedTypes[attribute] ? supportedTypes[attribute].start : "", false); container << parent.hubiForm_text_input (this, "End event value", "attribute_${sensor.id}_${attribute}_end", supportedTypes[attribute] ? supportedTypes[attribute].end : "", false); parent.hubiForm_container(this, container, 1); } cnt += 1; } } } } def graphSetupPage(){ dynamicPage(name: "graphSetupPage") { parent.hubiForm_section(this, "General Options", 1, "directions"){ input( type: "enum", name: "graph_update_rate", title: "Select graph update rate", multiple: false, required: false, options: [["-1":"Never"], ["0":"Real Time"], ["10":"10 Milliseconds"], ["1000":"1 Second"], ["5000":"5 Seconds"], ["60000":"1 Minute"], ["300000":"5 Minutes"], ["600000":"10 Minutes"], ["1800000":"Half Hour"], ["3600000":"1 Hour"]], defaultValue: "0") input( type: "enum", name: "graph_timespan", title: "Select Timespan to Graph", multiple: false, required: false, options: [["60000":"1 Minute"], ["3600000":"1 Hour"], ["43200000":"12 Hours"], ["86400000":"1 Day"], ["259200000":"3 Days"], ["604800000":"1 Week"]], defaultValue: "43200000") input( type: "enum", name: "graph_combine_rate", title: "Combine events with events less than ? apart", multiple: false, required: false, options: [["0":"Never"], ["10000":"10 Seconds"], ["30000":"30 seconds"], ["60000":"1 Minute"], ["120000":"2 Minutes"], ["180000":"3 Minutes"], ["240000":"4 Minutes"], ["300000":"5 Minutes"], ["600000":"10 Minutes"], ["1200000":"20 Minutes"], ["1800000":"30 Minutes"], ["3600000":"1 Hour"], ["6400000":"2 Hours"], ["9600000":"3 Hours"], ["13200000":"4 Hours"], ["16800000":"5 Hours"], ["20400000":"6 Hours"]], defaultValue: "Never") container = []; container << parent.hubiForm_color (this, "Background", "graph_background", "#FFFFFF", false); } parent.hubiForm_section(this, "Graph Size", 1){ container = []; input( type: "bool", name: "graph_static_size", title: "Set size of Graph?
(False = Fill Window)", defaultValue: false, submitOnChange: true); if (graph_static_size==true){ container << parent.hubiForm_slider (this, title: "Horizontal dimension of the graph", name: "graph_h_size", default_value: 800, min: 100, max: 3000, units: " pixels", submit_on_change: false); container << parent.hubiForm_slider (this, title: "Vertical dimension of the graph", name: "graph_v_size", default_value: 600, min: 100, max: 3000, units: " pixels", submit_on_change: false); } parent.hubiForm_container(this, container, 1); } parent.hubiForm_section(this, "Devices", 1){ container = []; container << parent.hubiForm_color (this, "Device Text", "graph_axis", "#FFFFFF", false); container << parent.hubiForm_font_size (this, title: "Device", name: "graph_axis", default: 9, min: 2, max: 20); parent.hubiForm_container(this, container, 1); } } } def disableAPIPage() { dynamicPage(name: "disableAPIPage") { section() { if (state.endpoint) { try { revokeAccessToken(); } catch (e) { log.debug "Unable to revoke access token: $e" } state.endpoint = null } paragraph "It has been done. Your token has been REVOKED. Tap Done to continue." } } } def enableAPIPage() { dynamicPage(name: "enableAPIPage", title: "") { section() { if(!state.endpoint) initializeAppEndpoint(); if (!state.endpoint){ paragraph "Endpoint creation failed" } else { paragraph "It has been done. Your token has been CREATED. Tap Done to continue." } } } } def mainPage() { dynamicPage(name: "mainPage") { def container = []; if (!state.endpoint) { parent.hubiForm_section(this, "Please set up OAuth API", 1, "report"){ href name: "enableAPIPageLink", title: "Enable API", description: "", page: "enableAPIPage" } } else { parent.hubiForm_section(this, "Graph Options", 1, "tune"){ container = []; container << parent.hubiForm_page_button(this, "Select Device/Data", "deviceSelectionPage", "100%", "vibration"); container << parent.hubiForm_page_button(this, "Configure Graph", "graphSetupPage", "100%", "poll"); parent.hubiForm_container(this, container, 1); } parent.hubiForm_section(this, "Local Graph URL", 1, "link"){ container = []; container << parent.hubiForm_text(this, "${state.localEndpointURL}graph/?access_token=${state.endpointSecret}"); parent.hubiForm_container(this, container, 1); } if (graph_update_rate){ parent.hubiForm_section(this, "Preview", 10, "show_chart"){ container = []; container << parent.hubiForm_graph_preview(this) parent.hubiForm_container(this, container, 1); } //graph_timespan parent.hubiForm_section(this, "Hubigraph Tile Installation", 2, "apps"){ container = []; container << parent.hubiForm_switch(this, title: "Install Hubigraph Tile Device?", name: "install_device", default: false, submit_on_change: true); if (install_device==true){ container << parent.hubiForm_text_input(this, "Name for HubiGraph Tile Device", "device_name", "Hubigraph Tile", "false"); } parent.hubiForm_container(this, container, 1); } } if (state.endpoint){ parent.hubiForm_section(this, "Hubigraph Application", 1, "settings"){ container = []; container << parent.hubiForm_sub_section(this, "Application Name"); container << parent.hubiForm_text_input(this, "Rename the Application?", "app_name", "Hubigraph Bar Graph", "false"); container << parent.hubiForm_sub_section(this, "Debugging"); container << parent.hubiForm_switch(this, title: "Enable Debug Logging?", name: "debug", default: false); container << parent.hubiForm_sub_section(this, "Disable Oauth Authorization"); container << parent.hubiForm_page_button(this, "Disable API", "disableAPIPage", "100%", "cancel"); parent.hubiForm_container(this, container, 1); } } } //else } //dynamicPage } def installed() { log.debug "Installed with settings: ${settings}" updated(); } def uninstalled() { if (state.endpoint) { try { log.debug "Revoking API access token" revokeAccessToken() } catch (e) { log.warn "Unable to revoke API access token: $e" } } removeChildDevices(getChildDevices()); } private removeChildDevices(delete) { delete.each {deleteChildDevice(it.deviceNetworkId)} } def updated() { app.updateLabel(app_name); state.dataName = attribute; if (install_device == true){ parent.hubiTool_create_tile(this); } } def buildData() { def resp = [:] def now = new Date(); def then = new Date(); use (groovy.time.TimeCategory) { then -= Integer.parseInt(graph_timespan).milliseconds; } if(sensors) { sensors.each {sensor -> def attributes = settings["attributes_${sensor.id}"]; resp[sensor.id] = [:]; attributes.each { attribute -> temp = sensor.statesSince(attribute, then, [max: 50000]).collect{[ date: it.date, value: it.value ]} temp = temp.sort{ it.date }; temp = temp.collect{ [date: it.date.getTime(), value: it.value] } resp[sensor.id][attribute] = temp; } } } return resp } def getChartOptions(){ def colors = []; order = parent.hubiTools_get_order(graph_order); order.each{ device-> attrib_string = "attribute_${device.id}_${device.attribute}_line_color" transparent_attrib_string = "attribute_${device.id}_${device.attribute}_line_color_transparent" colors << (settings[transparent_attrib_string] ? "transparent" : settings[attrib_string]); } /* sensors.each {sensor-> def attributes = settings["attributes_${sensor.id}"]; attributes.each {attribute-> attrib_string = "attribute_${sensor.id}_${attribute}_line_color" transparent_attrib_string = "attribute_${sensor.id}_${attribute}_line_color_transparent" colors << (settings[transparent_attrib_string] ? "transparent" : settings[attrib_string]); } } */ def options = [ "graphTimespan": Integer.parseInt(graph_timespan), "graphUpdateRate": Integer.parseInt(graph_update_rate), "graphCombine_msecs": Integer.parseInt(graph_combine_rate), "graphOptions": [ "width": graph_static_size ? graph_h_size : "100%", "height": graph_static_size ? graph_v_size: "100%", "timeline": [ "rowLabelStyle": ["fontSize": graph_axis_font, "color": graph_axis_color_transparent ? "transparent" : graph_axis_color], "barLabelStyle": ["fontSize": graph_axis_font], ], "haxis" : [ "text": ["fontSize": "24px"]], "backgroundColor": graph_background_color_transparent ? "transparent" : graph_background_color, "colors" : colors ], ] return options; } void removeLastChar(str) { str.subSequence(0, str.length() - 1) } def getTimeLine() { def fullSizeStyle = "margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden"; def html = """
""" return html; } // Create a formatted date object string for Google Charts Timeline def getDateString(date) { def dateObj = Date.parse("yyyy-MM-dd HH:mm:ss.SSS", date.toString()) //def dateObj = date def year = dateObj.getYear() + 1900 def dateString = "new Date(${year}, ${dateObj.getMonth()}, ${dateObj.getDate()}, ${dateObj.getHours()}, ${dateObj.getMinutes()}, ${dateObj.getSeconds()})" dateString } // Events come in Date format def getDateStringEvent(date) { def dateObj = date def yyyy = dateObj.getYear() + 1900 def MM = String.format("%02d", dateObj.getMonth()+1); def dd = String.format("%02d", dateObj.getDate()); def HH = String.format("%02d", dateObj.getHours()); def mm = String.format("%02d", dateObj.getMinutes()); def ss = String.format("%02d", dateObj.getSeconds()); def dateString = /$yyyy-$MM-$dd $HH:$mm:$ss.000/; dateString } def initializeAppEndpoint() { if (!state.endpoint) { try { def accessToken = createAccessToken() if (accessToken) { state.endpoint = getApiServerUrl() state.localEndpointURL = fullLocalApiServerUrl("") state.remoteEndpointURL = fullApiServerUrl("") state.endpointSecret = accessToken } } catch(e) { log.debug("Error: $e"); state.endpoint = null } } return state.endpoint } def getColorCode(code){ ret = "#FFFFFF" switch (code){ case 7: ret = "#800000"; break; case 1: ret = "#FF0000"; break; case 6: ret = "#FFA500"; break; case 8: ret = "#FFFF00"; break; case 9: ret = "#808000"; break; case 2: ret = "#008000"; break; case 5: ret = "#800080"; break; case 4: ret = "#FF00FF"; break; case 10: ret = "#00FF00"; break; case 11: ret = "#008080"; break; case 12: ret = "#00FFFF"; break; case 3: ret = "#0000FF"; break; case 13: ret = "#000080"; break; } return ret; } //oauth endpoints def getGraph() { return render(contentType: "text/html", data: getTimeLine()); } def getData() { def timeline = buildData(); return render(contentType: "text/json", data: JsonOutput.toJson(timeline)); } def getOptions() { return render(contentType: "text/json", data: JsonOutput.toJson(getChartOptions())); } def getSubscriptions() { def definitions = [:]; def labels = [:]; sensors.each { sensor -> definitions[sensor.id] = [:]; labels[sensor.id] = [:]; def attributes = settings["attributes_${sensor.id}"]; attributes.each { attribute -> definitions[sensor.id][attribute] = ["start": settings["attribute_${sensor.id}_${attribute}_start"], "end": settings["attribute_${sensor.id}_${attribute}_end"]]; labels[sensor.id][attribute] = settings["graph_name_override_${sensor.id}_${attribute}"]; } } def sensors_fmt = [:]; sensors.each { it -> sensors_fmt[it.id] = [ "id": it.id, "displayName": it.displayName, "currentStates": it.currentStates ]; } def order = parseJson(graph_order); def subscriptions = [ "sensors": sensors_fmt, "definitions": definitions, "labels": labels, "order": order ]; return render(contentType: "text/json", data: JsonOutput.toJson(subscriptions)); }