mirror of
https://github.com/mtan93/SmartThingsPublic.git
synced 2026-03-18 05:10:52 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2080aef0e7 | ||
|
|
29f315ac33 | ||
|
|
2baac34ba3 | ||
|
|
811a1af4bf | ||
|
|
b765b46c50 | ||
|
|
6eac6affcf | ||
|
|
d2cecf6908 | ||
|
|
61d2aac45a | ||
|
|
b5d0a5e74b | ||
|
|
1c2189b63c | ||
|
|
1adb4000a6 | ||
|
|
bad978afbd | ||
|
|
80b4d6a665 | ||
|
|
f51d6542b8 | ||
|
|
7baad1c35e | ||
|
|
621bcfadd2 | ||
|
|
83bbaebef2 | ||
|
|
bde5abcdb5 | ||
|
|
99e48dbeed | ||
|
|
2ee1b26a7f | ||
|
|
a7ed8f4afe | ||
|
|
148ee3521d | ||
|
|
d7490a086a | ||
|
|
189bec58db | ||
|
|
7e26fd1040 | ||
|
|
a244808073 | ||
|
|
33f1209c80 | ||
|
|
d977c4d46b | ||
|
|
01b2f57d7a | ||
|
|
ab00d703bf | ||
|
|
6c3a7886ed | ||
|
|
5719bbcaac | ||
|
|
324d9bf780 | ||
|
|
eb8d861c6c | ||
|
|
99a4d75e4b | ||
|
|
d601484398 | ||
|
|
a3fbc8ebd8 | ||
|
|
bfc14ffd9e | ||
|
|
1c327d1433 | ||
|
|
deee914573 | ||
|
|
f6b541c30f | ||
|
|
c00fbd3652 | ||
|
|
76f056180d | ||
|
|
c39b63b944 | ||
|
|
40bf47ec0b | ||
|
|
dc2ac4bedc | ||
|
|
0cf90064ec | ||
|
|
0497660ab5 | ||
|
|
6005f7266e | ||
|
|
5d38cabe75 | ||
|
|
29f94ee6ac | ||
|
|
8929673ff0 | ||
|
|
94228c258a | ||
|
|
1b424a8ea8 | ||
|
|
35a7a79073 | ||
|
|
8dd3b2396f | ||
|
|
39d2def035 | ||
|
|
e1a9f2f761 | ||
|
|
37eb8cc0a1 | ||
|
|
402b0be80b | ||
|
|
a761590322 | ||
|
|
8d4a73cd30 | ||
|
|
4bf2b55c26 | ||
|
|
13fcbfcdc7 | ||
|
|
288a4e99fa | ||
|
|
55a3842b00 | ||
|
|
a25fb6d84d | ||
|
|
d9a2d8109e | ||
|
|
0376a5823f | ||
|
|
78e25c6958 | ||
|
|
7e5d6e99d1 | ||
|
|
f5e3a16ad1 | ||
|
|
c361387bf8 | ||
|
|
f0c6e085eb | ||
|
|
67a1bd1256 | ||
|
|
32182ae13b | ||
|
|
e56086aac3 | ||
|
|
6b142622db | ||
|
|
8d07e81b80 | ||
|
|
0fcff53eba |
@@ -56,7 +56,7 @@ metadata {
|
|||||||
state "configure", label: '', action: "configuration.configure", icon: "st.secondary.configure"
|
state "configure", label: '', action: "configuration.configure", icon: "st.secondary.configure"
|
||||||
}
|
}
|
||||||
|
|
||||||
PLATFORM_graphTile(name: "powerGraph", attribute: "device.power")
|
graphTile(name: "powerGraph", attribute: "device.power")
|
||||||
|
|
||||||
main(["power", "energy"])
|
main(["power", "energy"])
|
||||||
details(["powerGraph", "power", "energy", "reset", "refresh", "configure"])
|
details(["powerGraph", "power", "energy", "reset", "refresh", "configure"])
|
||||||
@@ -68,16 +68,8 @@ metadata {
|
|||||||
// ========================================================
|
// ========================================================
|
||||||
|
|
||||||
preferences {
|
preferences {
|
||||||
input name: "graphPrecision", type: "enum", title: "Graph Precision", description: "Daily", required: true, options: PLATFORM_graphPrecisionOptions(), defaultValue: "Daily"
|
input name: "graphPrecision", type: "enum", title: "Graph Precision", description: "Daily", required: true, options: graphPrecisionOptions(), defaultValue: "Daily"
|
||||||
input name: "graphType", type: "enum", title: "Graph Type", description: selectedGraphType(), required: false, options: PLATFORM_graphTypeOptions()
|
input name: "graphType", type: "enum", title: "Graph Type", description: "line", required: false, options: graphTypeOptions()
|
||||||
}
|
|
||||||
|
|
||||||
def selectedGraphPrecision() {
|
|
||||||
graphPrecision ?: "Daily"
|
|
||||||
}
|
|
||||||
|
|
||||||
def selectedGraphType() {
|
|
||||||
graphType ?: "line"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================
|
// ========================================================
|
||||||
@@ -91,22 +83,6 @@ mappings {
|
|||||||
GET: "renderGraph"
|
GET: "renderGraph"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
path("/graphDataSizes") { // for testing. remove before publishing
|
|
||||||
action:
|
|
||||||
[
|
|
||||||
GET: "graphDataSizes"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def graphDataSizes() { // for testing. remove before publishing
|
|
||||||
state.findAll { k, v -> k.startsWith("measure.") }.inject([:]) { attributes, attributeData ->
|
|
||||||
attributes[attributeData.key] = attributeData.value.inject([:]) { dateTypes, dateTypeData ->
|
|
||||||
dateTypes[dateTypeData.key] = dateTypeData.value.size()
|
|
||||||
dateTypes
|
|
||||||
}
|
|
||||||
attributes
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================
|
// ========================================================
|
||||||
@@ -121,8 +97,7 @@ def parse(String description) {
|
|||||||
}
|
}
|
||||||
log.debug "Parse returned ${result?.descriptionText}"
|
log.debug "Parse returned ${result?.descriptionText}"
|
||||||
|
|
||||||
PLATFORM_migrateGraphDataIfNeeded()
|
storeGraphData(result.name, result.value)
|
||||||
PLATFORM_storeData(result.name, result.value)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -176,535 +151,15 @@ def configure() {
|
|||||||
|
|
||||||
def renderGraph() {
|
def renderGraph() {
|
||||||
|
|
||||||
def data = PLATFORM_fetchGraphData(params.attribute)
|
def data = fetchGraphData(params.attribute)
|
||||||
|
|
||||||
def totalData = data*.runningSum
|
def totalData = data*.runningSum
|
||||||
|
|
||||||
def xValues = data*.unixTime
|
def xValues = data*.unixTime
|
||||||
|
|
||||||
def yValues = [
|
def yValues = [
|
||||||
Total: [color: "#49a201", data: totalData, type: selectedGraphType()]
|
Total: [color: "#49a201", data: totalData]
|
||||||
]
|
]
|
||||||
|
|
||||||
PLATFORM_renderGraph(attribute: params.attribute, xValues: xValues, yValues: yValues, focus: "Total", label: "Watts")
|
renderGraph(attribute: params.attribute, xValues: xValues, yValues: yValues, focus: "Total", label: "Watts")
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: // ========================================================
|
|
||||||
// TODO: // PLATFORM CODE !!! DO NOT ALTER !!!
|
|
||||||
// TODO: // ========================================================
|
|
||||||
|
|
||||||
// ========================================================
|
|
||||||
// PLATFORM TILES
|
|
||||||
// ========================================================
|
|
||||||
|
|
||||||
def PLATFORM_graphTile(Map tileParams) {
|
|
||||||
def cleanAttribute = tileParams.attribute - "device." - "capability."
|
|
||||||
htmlTile([name: tileParams.name, attribute: tileParams.attribute, action: "graph/${cleanAttribute}", width: 3, height: 2] + tileParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================
|
|
||||||
// PLATFORM GRAPH RENDERING
|
|
||||||
// ========================================================
|
|
||||||
|
|
||||||
private PLATFORM_graphTypeOptions() {
|
|
||||||
[
|
|
||||||
"line", // DEFAULT
|
|
||||||
"spline",
|
|
||||||
"step",
|
|
||||||
"area",
|
|
||||||
"area-spline",
|
|
||||||
"area-step",
|
|
||||||
"bar",
|
|
||||||
"scatter",
|
|
||||||
"pie",
|
|
||||||
"donut",
|
|
||||||
"gauge",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private PLATFORM_renderGraph(graphParams) {
|
|
||||||
|
|
||||||
String attribute = graphParams.attribute
|
|
||||||
List xValues = graphParams.xValues
|
|
||||||
Map yValues = graphParams.yValues
|
|
||||||
String focus = graphParams.focus ?: ""
|
|
||||||
String label = graphParams.label ?: ""
|
|
||||||
|
|
||||||
/*
|
|
||||||
def xValues = [1, 2]
|
|
||||||
|
|
||||||
def yValues = [
|
|
||||||
High: [type: "spline", data: [5, 6], color: "#bc2323"],
|
|
||||||
Low: [type: "spline", data: [0, 1], color: "#153591"]
|
|
||||||
]
|
|
||||||
|
|
||||||
Available type values:
|
|
||||||
line // DEFAULT
|
|
||||||
spline
|
|
||||||
step
|
|
||||||
area
|
|
||||||
area-spline
|
|
||||||
area-step
|
|
||||||
bar
|
|
||||||
scatter
|
|
||||||
pie
|
|
||||||
donut
|
|
||||||
gauge
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
def graphData = PLATFORM_buildGraphData(xValues, yValues, label)
|
|
||||||
|
|
||||||
def legendData = yValues*.key
|
|
||||||
def focusJS = focus ? "chart.focus('${focus}')" : "// focus not specified"
|
|
||||||
def flowColumn = focus ?: yValues ? yValues.keySet().first() : null
|
|
||||||
|
|
||||||
def htmlTitle = "${(device.label ?: device.name)} ${attribute.capitalize()} Graph"
|
|
||||||
renderHTML(htmlTitle) { html ->
|
|
||||||
html.head {
|
|
||||||
"""
|
|
||||||
<!-- Load c3.css -->
|
|
||||||
<link href="https://www.dropbox.com/s/m6ptp72cw4nx0sp/c3.css?dl=1" rel="stylesheet" type="text/css">
|
|
||||||
|
|
||||||
<!-- Load d3.js and c3.js -->
|
|
||||||
<script src="https://www.dropbox.com/s/9x22jyfu5qyacpp/d3.v3.min.js?dl=1" charset="utf-8"></script>
|
|
||||||
<script src="https://www.dropbox.com/s/to7dtcn403l7mza/c3.js?dl=1"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function getDocumentHeight() {
|
|
||||||
var body = document.body;
|
|
||||||
var html = document.documentElement;
|
|
||||||
|
|
||||||
return html.clientHeight;
|
|
||||||
}
|
|
||||||
function getDocumentWidth() {
|
|
||||||
var body = document.body;
|
|
||||||
var html = document.documentElement;
|
|
||||||
|
|
||||||
return html.clientWidth;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.legend {
|
|
||||||
position: absolute;
|
|
||||||
width: 80%;
|
|
||||||
padding-left: 15%;
|
|
||||||
z-index: 999;
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
.legend span {
|
|
||||||
width: ${100 / yValues.size()}%;
|
|
||||||
display: inline-block;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
html.body {
|
|
||||||
"""
|
|
||||||
<div class="legend"></div>
|
|
||||||
<div id="chart" style="max-height: 120px; position: relative;"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
// Generate the chart
|
|
||||||
var chart = c3.generate(${graphData as grails.converters.JSON});
|
|
||||||
|
|
||||||
// Resize the chart to the size of the device tile
|
|
||||||
chart.resize({height:getDocumentHeight(), width:getDocumentWidth()});
|
|
||||||
|
|
||||||
// Focus data if specified
|
|
||||||
${focusJS}
|
|
||||||
|
|
||||||
// Update the chart when ${attribute} events are received
|
|
||||||
function ${attribute}(evt) {
|
|
||||||
var newValue = ['${flowColumn}'];
|
|
||||||
newValue.push(evt.value);
|
|
||||||
|
|
||||||
var newX = ['x'];
|
|
||||||
newX.push(evt.unixTime);
|
|
||||||
|
|
||||||
chart.flow({
|
|
||||||
columns: [
|
|
||||||
newX,
|
|
||||||
newValue
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the custom legend
|
|
||||||
d3.select('.legend').selectAll('span')
|
|
||||||
.data(${legendData as grails.converters.JSON})
|
|
||||||
.enter().append('span')
|
|
||||||
.attr('data-id', function (id) { return id; })
|
|
||||||
.html(function (id) { return id; })
|
|
||||||
.each(function (id) {
|
|
||||||
d3.select(this).style('background-color', chart.color(id));
|
|
||||||
})
|
|
||||||
.on('mouseover', function (id) {
|
|
||||||
chart.focus(id);
|
|
||||||
})
|
|
||||||
.on('mouseout', function (id) {
|
|
||||||
chart.revert();
|
|
||||||
})
|
|
||||||
.on('click', function (id) {
|
|
||||||
chart.toggle(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private PLATFORM_buildGraphData(List xValues, Map yValues, String label = "") {
|
|
||||||
|
|
||||||
/*
|
|
||||||
def xValues = [1, 2]
|
|
||||||
|
|
||||||
def yValues = [
|
|
||||||
High: [type: "spline", data: [5, 6], color: "#bc2323"],
|
|
||||||
Low: [type: "spline", data: [0, 1], color: "#153591"]
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
|
|
||||||
[
|
|
||||||
interaction: [
|
|
||||||
enabled: false
|
|
||||||
],
|
|
||||||
bindto : '#chart',
|
|
||||||
padding : [
|
|
||||||
left : 30,
|
|
||||||
right : 30,
|
|
||||||
bottom: 0,
|
|
||||||
top : 0
|
|
||||||
],
|
|
||||||
legend : [
|
|
||||||
show: false,
|
|
||||||
// hide : false,//(yValues.keySet().size() < 2),
|
|
||||||
// position: 'inset',
|
|
||||||
// inset: [
|
|
||||||
// anchor: "top-right"
|
|
||||||
// ],
|
|
||||||
// item: [
|
|
||||||
// onclick: "do nothing" // (yValues.keySet().size() > 1) ? null : "do nothing"
|
|
||||||
// ]
|
|
||||||
],
|
|
||||||
data : [
|
|
||||||
x : "x",
|
|
||||||
columns: [(["x"] + xValues)] + yValues.collect { k, v -> [k] + v.data },
|
|
||||||
types : yValues.inject([:]) { total, current -> total[current.key] = current.value.type; return total },
|
|
||||||
colors : yValues.inject([:]) { total, current -> total[current.key] = current.value.color; return total }
|
|
||||||
],
|
|
||||||
axis : [
|
|
||||||
x: [
|
|
||||||
type: 'timeseries',
|
|
||||||
tick: [
|
|
||||||
centered: true,
|
|
||||||
culling : [max: 7],
|
|
||||||
fit : true,
|
|
||||||
format : PLATFORM_getGraphDateFormat()
|
|
||||||
// format: PLATFORM_getGraphDateFormatFunction() // throws securityException when trying to escape javascript
|
|
||||||
]
|
|
||||||
],
|
|
||||||
y: [
|
|
||||||
label : label,
|
|
||||||
padding: [
|
|
||||||
top: 50
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private PLATFORM_getGraphDateFormat(dateType = selectedGraphPrecision()) {
|
|
||||||
// https://github.com/mbostock/d3/wiki/Time-Formatting
|
|
||||||
def graphDateFormat
|
|
||||||
switch (dateType) {
|
|
||||||
case "Live":
|
|
||||||
graphDateFormat = "%I:%M" // hour (12-hour clock) as a decimal number [00,12] // AM or PM
|
|
||||||
break
|
|
||||||
case "Hourly":
|
|
||||||
graphDateFormat = "%I %p" // hour (12-hour clock) as a decimal number [00,12] // AM or PM
|
|
||||||
break
|
|
||||||
case "Daily":
|
|
||||||
graphDateFormat = "%a" // abbreviated weekday name
|
|
||||||
break
|
|
||||||
case "Monthly":
|
|
||||||
graphDateFormat = "%b" // abbreviated month name
|
|
||||||
break
|
|
||||||
case "Annually":
|
|
||||||
graphDateFormat = "%y" // year without century as a decimal number [00,99]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
graphDateFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
private String PLATFORM_getGraphDateFormatFunction(dateType = selectedGraphPrecision()) {
|
|
||||||
def graphDateFunction = "function(date) { return date; }"
|
|
||||||
switch (dateType) {
|
|
||||||
case "Live":
|
|
||||||
graphDateFunction = """
|
|
||||||
function(date) {
|
|
||||||
return.getMinutes();
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
break;
|
|
||||||
case "Hourly":
|
|
||||||
graphDateFunction = """ function(date) {
|
|
||||||
var hour = date.getHours();
|
|
||||||
if (hour == 0) {
|
|
||||||
return String(/12 am/).substring(1).slice(0,-1);
|
|
||||||
} else if (hour > 12) {
|
|
||||||
return hour -12 + String(/ pm/).substring(1).slice(0,-1);
|
|
||||||
} else {
|
|
||||||
return hour + String(/ am/).substring(1).slice(0,-1);
|
|
||||||
}
|
|
||||||
}"""
|
|
||||||
break
|
|
||||||
case "Daily":
|
|
||||||
graphDateFunction = """ function(date) {
|
|
||||||
var day = date.getDay();
|
|
||||||
switch(day) {
|
|
||||||
case 0: return String(/Sun/).substring(1).slice(0,-1);
|
|
||||||
case 1: return String(/Mon/).substring(1).slice(0,-1);
|
|
||||||
case 2: return String(/Tue/).substring(1).slice(0,-1);
|
|
||||||
case 3: return String(/Wed/).substring(1).slice(0,-1);
|
|
||||||
case 4: return String(/Thu/).substring(1).slice(0,-1);
|
|
||||||
case 5: return String(/Fri/).substring(1).slice(0,-1);
|
|
||||||
case 6: return String(/Sat/).substring(1).slice(0,-1);
|
|
||||||
}
|
|
||||||
}"""
|
|
||||||
break
|
|
||||||
case "Monthly":
|
|
||||||
graphDateFunction = """ function(date) {
|
|
||||||
var month = date.getMonth();
|
|
||||||
switch(month) {
|
|
||||||
case 0: return String(/Jan/).substring(1).slice(0,-1);
|
|
||||||
case 1: return String(/Feb/).substring(1).slice(0,-1);
|
|
||||||
case 2: return String(/Mar/).substring(1).slice(0,-1);
|
|
||||||
case 3: return String(/Apr/).substring(1).slice(0,-1);
|
|
||||||
case 4: return String(/May/).substring(1).slice(0,-1);
|
|
||||||
case 5: return String(/Jun/).substring(1).slice(0,-1);
|
|
||||||
case 6: return String(/Jul/).substring(1).slice(0,-1);
|
|
||||||
case 7: return String(/Aug/).substring(1).slice(0,-1);
|
|
||||||
case 8: return String(/Sep/).substring(1).slice(0,-1);
|
|
||||||
case 9: return String(/Oct/).substring(1).slice(0,-1);
|
|
||||||
case 10: return String(/Nov/).substring(1).slice(0,-1);
|
|
||||||
case 11: return String(/Dec/).substring(1).slice(0,-1);
|
|
||||||
}
|
|
||||||
}"""
|
|
||||||
break
|
|
||||||
case "Annually":
|
|
||||||
graphDateFunction = """
|
|
||||||
function(date) {
|
|
||||||
return.getFullYear();
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
break
|
|
||||||
}
|
|
||||||
groovy.json.StringEscapeUtils.escapeJavaScript(graphDateFunction)
|
|
||||||
}
|
|
||||||
|
|
||||||
private jsEscapeString(str = "") {
|
|
||||||
"String(/${str}/).substring(1).slice(0,-1);"
|
|
||||||
}
|
|
||||||
|
|
||||||
private PLATFORM_fetchGraphData(attribute) {
|
|
||||||
|
|
||||||
log.debug "PLATFORM_fetchGraphData(${attribute})"
|
|
||||||
|
|
||||||
/*
|
|
||||||
[
|
|
||||||
[
|
|
||||||
dateString: "2014-12-1",
|
|
||||||
unixTime: 1421931600000,
|
|
||||||
min: 0,
|
|
||||||
max: 10,
|
|
||||||
average: 5
|
|
||||||
],
|
|
||||||
...
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
|
|
||||||
def attributeBucket = state["measure.${attribute}"] ?: [:]
|
|
||||||
def dateType = selectedGraphPrecision()
|
|
||||||
attributeBucket[dateType]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================
|
|
||||||
// PLATFORM DATA STORAGE
|
|
||||||
// ========================================================
|
|
||||||
|
|
||||||
private PLATFORM_graphPrecisionOptions() { ["Live", "Hourly", "Daily", "Monthly", "Annually"] }
|
|
||||||
|
|
||||||
private PLATFORM_storeData(attribute, value) {
|
|
||||||
PLATFORM_graphPrecisionOptions().each { dateType ->
|
|
||||||
PLATFORM_addDataToBucket(attribute, value, dateType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
[
|
|
||||||
Hourly: [
|
|
||||||
[
|
|
||||||
dateString: "2014-12-1",
|
|
||||||
unixTime: 1421931600000,
|
|
||||||
min: 0,
|
|
||||||
max: 10,
|
|
||||||
average: 5
|
|
||||||
],
|
|
||||||
...
|
|
||||||
],
|
|
||||||
...
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
|
|
||||||
private PLATFORM_addDataToBucket(attribute, value, dateType) {
|
|
||||||
|
|
||||||
def numberValue = value.toBigDecimal()
|
|
||||||
|
|
||||||
def attributeKey = "measure.${attribute}"
|
|
||||||
def attributeBucket = state[attributeKey] ?: [:]
|
|
||||||
|
|
||||||
def dateTypeBucket = attributeBucket[dateType] ?: []
|
|
||||||
|
|
||||||
def now = new Date()
|
|
||||||
def itemDateString = now.format("PLATFORM_get${dateType}Format"())
|
|
||||||
def item = dateTypeBucket.find { it.dateString == itemDateString }
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
// no entry for this data point yet, fill with initial values
|
|
||||||
item = [:]
|
|
||||||
item.average = numberValue
|
|
||||||
item.runningSum = numberValue
|
|
||||||
item.runningCount = 1
|
|
||||||
item.min = numberValue
|
|
||||||
item.max = numberValue
|
|
||||||
item.unixTime = now.getTime()
|
|
||||||
item.dateString = itemDateString
|
|
||||||
|
|
||||||
// add the new data point
|
|
||||||
dateTypeBucket << item
|
|
||||||
|
|
||||||
// clear out old data points
|
|
||||||
def old = PLATFORM_getOldDateString(dateType)
|
|
||||||
if (old) { // annual data never gets cleared
|
|
||||||
dateTypeBucket.findAll { it.unixTime < old }.each { dateTypeBucket.remove(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// limit the size of the bucket. Live data can stack up fast
|
|
||||||
def sizeLimit = 25
|
|
||||||
if (dateTypeBucket.size() > sizeLimit) {
|
|
||||||
dateTypeBucket = dateTypeBucket[-sizeLimit..-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
//re-calculate average/min/max for this bucket
|
|
||||||
item.runningSum = (item.runningSum.toBigDecimal()) + numberValue
|
|
||||||
item.runningCount = item.runningCount.toInteger() + 1
|
|
||||||
item.average = item.runningSum.toBigDecimal() / item.runningCount.toInteger()
|
|
||||||
|
|
||||||
if (item.min == null) {
|
|
||||||
item.min = numberValue
|
|
||||||
} else if (numberValue < item.min.toBigDecimal()) {
|
|
||||||
item.min = numberValue
|
|
||||||
}
|
|
||||||
if (item.max == null) {
|
|
||||||
item.max = numberValue
|
|
||||||
} else if (numberValue > item.max.toBigDecimal()) {
|
|
||||||
item.max = numberValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeBucket[dateType] = dateTypeBucket
|
|
||||||
state[attributeKey] = attributeBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
private PLATFORM_getOldDateString(dateType) {
|
|
||||||
def now = new Date()
|
|
||||||
def date
|
|
||||||
switch (dateType) {
|
|
||||||
case "Live":
|
|
||||||
date = now.getTime() - 60 * 60 * 1000 // 1h * 60m * 60s * 1000ms // 1 hour
|
|
||||||
break
|
|
||||||
case "Hourly":
|
|
||||||
date = (now - 1).getTime()
|
|
||||||
break
|
|
||||||
case "Daily":
|
|
||||||
date = (now - 10).getTime()
|
|
||||||
break
|
|
||||||
case "Monthly":
|
|
||||||
date = (now - 30).getTime()
|
|
||||||
break
|
|
||||||
case "Annually":
|
|
||||||
break
|
|
||||||
}
|
|
||||||
date
|
|
||||||
}
|
|
||||||
|
|
||||||
private PLATFORM_getLiveFormat() { "HH:mm:ss" }
|
|
||||||
|
|
||||||
private PLATFORM_getHourlyFormat() { "yyyy-MM-dd'T'HH" }
|
|
||||||
|
|
||||||
private PLATFORM_getDailyFormat() { "yyyy-MM-dd" }
|
|
||||||
|
|
||||||
private PLATFORM_getMonthlyFormat() { "yyyy-MM" }
|
|
||||||
|
|
||||||
private PLATFORM_getAnnuallyFormat() { "yyyy" }
|
|
||||||
|
|
||||||
// ========================================================
|
|
||||||
// PLATFORM GRAPH DATA MIGRATION
|
|
||||||
// ========================================================
|
|
||||||
|
|
||||||
private PLATFORM_migrateGraphDataIfNeeded() {
|
|
||||||
if (!state.hasMigratedOldGraphData) {
|
|
||||||
def acceptableKeys = PLATFORM_graphPrecisionOptions()
|
|
||||||
def needsMigration = state.findAll { k, v -> v.keySet().findAll { !acceptableKeys.contains(it) } }.keySet()
|
|
||||||
needsMigration.each { PLATFORM_migrateGraphData(it) }
|
|
||||||
state.hasMigratedOldGraphData = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private PLATFORM_migrateGraphData(attribute) {
|
|
||||||
|
|
||||||
log.trace "about to migrate ${attribute}"
|
|
||||||
|
|
||||||
def attributeBucket = state[attribute] ?: [:]
|
|
||||||
def migratedAttributeBucket = [:]
|
|
||||||
|
|
||||||
attributeBucket.findAll { k, v -> !PLATFORM_graphPrecisionOptions().contains(k) }.each { oldDateString, oldItem ->
|
|
||||||
|
|
||||||
def dateType = oldDateString.contains('T') ? "Hourly" : PLATFORM_graphPrecisionOptions().find {
|
|
||||||
"PLATFORM_get${it}Format"().size() == oldDateString.size()
|
|
||||||
}
|
|
||||||
|
|
||||||
def dateTypeFormat = "PLATFORM_get${dateType}Format"()
|
|
||||||
|
|
||||||
def newBucket = attributeBucket[dateType] ?: []
|
|
||||||
/*
|
|
||||||
def existingNewItem = newBucket.find { it.dateString == oldDateString }
|
|
||||||
if (existingNewItem) {
|
|
||||||
newBucket.remove(existingNewItem)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
def newItem = [
|
|
||||||
min : oldItem.min,
|
|
||||||
max : oldItem.max,
|
|
||||||
average : oldItem.average,
|
|
||||||
runningSum : oldItem.runningSum,
|
|
||||||
runningCount: oldItem.runningCount,
|
|
||||||
dateString : oldDateString,
|
|
||||||
unixTime : new Date().parse(dateTypeFormat, oldDateString).getTime()
|
|
||||||
]
|
|
||||||
|
|
||||||
newBucket << newItem
|
|
||||||
migratedAttributeBucket[dateType] = newBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
state[attribute] = migratedAttributeBucket
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
metadata {
|
metadata {
|
||||||
definition (name: "SmartSense Presence", namespace: "smartthings", author: "SmartThings") {
|
definition (name: "Arrival Sensor", namespace: "smartthings", author: "SmartThings") {
|
||||||
capability "Tone"
|
capability "Tone"
|
||||||
capability "Actuator"
|
capability "Actuator"
|
||||||
capability "Signal Strength"
|
capability "Signal Strength"
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
/**
|
||||||
|
* Bose SoundTouch
|
||||||
|
*
|
||||||
|
* Copyright 2015 SmartThings
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Needed to be able to serialize the XmlSlurper data back to XML
|
||||||
|
import groovy.xml.XmlUtil
|
||||||
|
|
||||||
|
// for the UI
|
||||||
|
metadata {
|
||||||
|
definition (name: "Bose SoundTouch", namespace: "smartthings", author: "SmartThings") {
|
||||||
|
/**
|
||||||
|
* List our capabilties. Doing so adds predefined command(s) which
|
||||||
|
* belong to the capability.
|
||||||
|
*/
|
||||||
|
capability "Switch"
|
||||||
|
capability "Refresh"
|
||||||
|
capability "Music Player"
|
||||||
|
capability "Polling"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define all commands, ie, if you have a custom action not
|
||||||
|
* covered by a capability, you NEED to define it here or
|
||||||
|
* the call will not be made.
|
||||||
|
*
|
||||||
|
* To call a capability function, just prefix it with the name
|
||||||
|
* of the capability, for example, refresh would be "refresh.refresh"
|
||||||
|
*/
|
||||||
|
command "preset1"
|
||||||
|
command "preset2"
|
||||||
|
command "preset3"
|
||||||
|
command "preset4"
|
||||||
|
command "preset5"
|
||||||
|
command "preset6"
|
||||||
|
command "aux"
|
||||||
|
|
||||||
|
command "everywhereJoin"
|
||||||
|
command "everywhereLeave"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the various tiles and the states that they can be in.
|
||||||
|
* The 2nd parameter defines an event which the tile listens to,
|
||||||
|
* if received, it tries to map it to a state.
|
||||||
|
*
|
||||||
|
* You can also use ${currentValue} for the value of the event
|
||||||
|
* or ${name} for the name of the event. Just make SURE to use
|
||||||
|
* single quotes, otherwise it will only be interpreted at time of
|
||||||
|
* launch, instead of every time the event triggers.
|
||||||
|
*/
|
||||||
|
valueTile("nowplaying", "device.nowplaying", width: 2, height: 1, decoration:"flat") {
|
||||||
|
state "nowplaying", label:'${currentValue}', action:"refresh.refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("switch", "device.switch", width: 1, height: 1, canChangeIcon: true) {
|
||||||
|
state "off", label: '${name}', action: "switch.on", icon: "st.Electronics.electronics16", backgroundColor: "#ffffff"
|
||||||
|
state "on", label: '${name}', action: "switch.off", icon: "st.Electronics.electronics16", backgroundColor: "#79b821"
|
||||||
|
}
|
||||||
|
valueTile("1", "device.station1", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "station1", label:'${currentValue}', action:"preset1"
|
||||||
|
}
|
||||||
|
valueTile("2", "device.station2", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "station2", label:'${currentValue}', action:"preset2"
|
||||||
|
}
|
||||||
|
valueTile("3", "device.station3", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "station3", label:'${currentValue}', action:"preset3"
|
||||||
|
}
|
||||||
|
valueTile("4", "device.station4", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "station4", label:'${currentValue}', action:"preset4"
|
||||||
|
}
|
||||||
|
valueTile("5", "device.station5", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "station5", label:'${currentValue}', action:"preset5"
|
||||||
|
}
|
||||||
|
valueTile("6", "device.station6", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "station6", label:'${currentValue}', action:"preset6"
|
||||||
|
}
|
||||||
|
valueTile("aux", "device.switch", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "default", label:'Auxillary\nInput', action:"aux"
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("refresh", "device.nowplaying", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "default", label:'', action:"refresh", icon:"st.secondary.refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("volume", "device.volume", "slider", height:1, width:3, range:"(0..100)") {
|
||||||
|
state "volume", action:"music Player.setLevel"
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("playpause", "device.playpause", decoration: "flat") {
|
||||||
|
state "pause", label:'', icon:'st.sonos.play-btn', action:'music Player.play'
|
||||||
|
state "play", label:'', icon:'st.sonos.pause-btn', action:'music Player.pause'
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("prev", "device.switch", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "default", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn"
|
||||||
|
}
|
||||||
|
standardTile("next", "device.switch", decoration: "flat", canChangeIcon: false) {
|
||||||
|
state "default", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn"
|
||||||
|
}
|
||||||
|
|
||||||
|
valueTile("everywhere", "device.everywhere", width:2, height:1, decoration:"flat") {
|
||||||
|
state "join", label:"Join\nEverywhere", action:"everywhereJoin"
|
||||||
|
state "leave", label:"Leave\nEverywhere", action:"everywhereLeave"
|
||||||
|
// Final state is used if the device is in a state where joining is not possible
|
||||||
|
state "unavailable", label:"Not Available"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defines which tile to show in the overview
|
||||||
|
main "switch"
|
||||||
|
|
||||||
|
// Defines which tile(s) to show when user opens the detailed view
|
||||||
|
details ([
|
||||||
|
"nowplaying", "refresh", // Row 1 (112)
|
||||||
|
"prev", "playpause", "next", // Row 2 (123)
|
||||||
|
"volume", // Row 3 (111)
|
||||||
|
"1", "2", "3", // Row 4 (123)
|
||||||
|
"4", "5", "6", // Row 5 (123)
|
||||||
|
"aux", "everywhere"]) // Row 6 (122)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**************************************************************************
|
||||||
|
* The following section simply maps the actions as defined in
|
||||||
|
* the metadata into onAction() calls.
|
||||||
|
*
|
||||||
|
* This is preferred since some actions can be dealt with more
|
||||||
|
* efficiently this way. Also keeps all user interaction code in
|
||||||
|
* one place.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
def off() { onAction("off") }
|
||||||
|
def on() { onAction("on") }
|
||||||
|
def volup() { onAction("volup") }
|
||||||
|
def voldown() { onAction("voldown") }
|
||||||
|
def preset1() { onAction("1") }
|
||||||
|
def preset2() { onAction("2") }
|
||||||
|
def preset3() { onAction("3") }
|
||||||
|
def preset4() { onAction("4") }
|
||||||
|
def preset5() { onAction("5") }
|
||||||
|
def preset6() { onAction("6") }
|
||||||
|
def aux() { onAction("aux") }
|
||||||
|
def refresh() { onAction("refresh") }
|
||||||
|
def setLevel(level) { onAction("volume", level) }
|
||||||
|
def play() { onAction("play") }
|
||||||
|
def pause() { onAction("pause") }
|
||||||
|
def mute() { onAction("mute") }
|
||||||
|
def unmute() { onAction("unmute") }
|
||||||
|
def previousTrack() { onAction("previous") }
|
||||||
|
def nextTrack() { onAction("next") }
|
||||||
|
def everywhereJoin() { onAction("ejoin") }
|
||||||
|
def everywhereLeave() { onAction("eleave") }
|
||||||
|
/**************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main point of interaction with things.
|
||||||
|
* This function is called by SmartThings Cloud with the resulting data from
|
||||||
|
* any action (see HubAction()).
|
||||||
|
*
|
||||||
|
* Conversely, to execute any actions, you need to return them as a single
|
||||||
|
* item or a list (flattened).
|
||||||
|
*
|
||||||
|
* @param data Data provided by the cloud
|
||||||
|
* @return an action or a list() of actions. Can also return null if no further
|
||||||
|
* action is desired at this point.
|
||||||
|
*/
|
||||||
|
def parse(String event) {
|
||||||
|
def data = parseLanMessage(event)
|
||||||
|
def actions = []
|
||||||
|
|
||||||
|
// List of permanent root node handlers
|
||||||
|
def handlers = [
|
||||||
|
"nowPlaying" : "boseParseNowPlaying",
|
||||||
|
"volume" : "boseParseVolume",
|
||||||
|
"presets" : "boseParsePresets",
|
||||||
|
"zone" : "boseParseEverywhere",
|
||||||
|
]
|
||||||
|
|
||||||
|
// No need to deal with non-XML data
|
||||||
|
if (!data.headers || !data.headers?."content-type".contains("xml"))
|
||||||
|
return null
|
||||||
|
|
||||||
|
// Move any pending callbacks into ready state
|
||||||
|
prepareCallbacks()
|
||||||
|
|
||||||
|
def xml = new XmlSlurper().parseText(data.body)
|
||||||
|
// Let each parser take a stab at it
|
||||||
|
handlers.each { node,func ->
|
||||||
|
if (xml.name() == node)
|
||||||
|
actions << "$func"(xml)
|
||||||
|
}
|
||||||
|
// If we have callbacks waiting for this...
|
||||||
|
actions << processCallbacks(xml)
|
||||||
|
|
||||||
|
// Be nice and helpful
|
||||||
|
if (actions.size() == 0) {
|
||||||
|
log.warn "parse(): Unhandled data = " + lan
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue new actions
|
||||||
|
return actions.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the devicetype is first installed.
|
||||||
|
*
|
||||||
|
* @return action(s) to take or null
|
||||||
|
*/
|
||||||
|
def installed() {
|
||||||
|
onAction("refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for dealing with user input and taking the
|
||||||
|
* appropiate action.
|
||||||
|
*
|
||||||
|
* @param user The user interaction
|
||||||
|
* @param data Additional data (optional)
|
||||||
|
* @return action(s) to take (or null if none)
|
||||||
|
*/
|
||||||
|
def onAction(String user, data=null) {
|
||||||
|
log.info "onAction(${user})"
|
||||||
|
|
||||||
|
// Keep IP address current (since device may have changed)
|
||||||
|
state.address = parent.resolveDNI2Address(device.deviceNetworkId)
|
||||||
|
|
||||||
|
// Process action
|
||||||
|
def actions = null
|
||||||
|
switch (user) {
|
||||||
|
case "on":
|
||||||
|
actions = boseSetPowerState(true)
|
||||||
|
break
|
||||||
|
case "off":
|
||||||
|
boseSetNowPlaying(null, "STANDBY")
|
||||||
|
actions = boseSetPowerState(false)
|
||||||
|
break
|
||||||
|
case "volume":
|
||||||
|
actions = boseSetVolume(data)
|
||||||
|
break
|
||||||
|
case "aux":
|
||||||
|
boseSetNowPlaying(null, "AUX")
|
||||||
|
boseZoneReset()
|
||||||
|
sendEvent(name:"everywhere", value:"unavailable")
|
||||||
|
case "1":
|
||||||
|
case "2":
|
||||||
|
case "3":
|
||||||
|
case "4":
|
||||||
|
case "5":
|
||||||
|
case "6":
|
||||||
|
actions = boseSetInput(user)
|
||||||
|
break
|
||||||
|
case "refresh":
|
||||||
|
boseSetNowPlaying(null, "REFRESH")
|
||||||
|
actions = [boseRefreshNowPlaying(), boseGetPresets(), boseGetVolume(), boseGetEverywhereState()]
|
||||||
|
break
|
||||||
|
case "play":
|
||||||
|
actions = [boseSetPlayMode(true), boseRefreshNowPlaying()]
|
||||||
|
break
|
||||||
|
case "pause":
|
||||||
|
actions = [boseSetPlayMode(false), boseRefreshNowPlaying()]
|
||||||
|
break
|
||||||
|
case "previous":
|
||||||
|
actions = [boseChangeTrack(-1), boseRefreshNowPlaying()]
|
||||||
|
break
|
||||||
|
case "next":
|
||||||
|
actions = [boseChangeTrack(1), boseRefreshNowPlaying()]
|
||||||
|
break
|
||||||
|
case "mute":
|
||||||
|
actions = boseSetMute(true)
|
||||||
|
break
|
||||||
|
case "unmute":
|
||||||
|
actions = boseSetMute(false)
|
||||||
|
break
|
||||||
|
case "ejoin":
|
||||||
|
actions = boseZoneJoin()
|
||||||
|
break
|
||||||
|
case "eleave":
|
||||||
|
actions = boseZoneLeave()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
log.error "Unhandled action: " + user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we don't have nested lists
|
||||||
|
if (actions instanceof List)
|
||||||
|
return actions.flatten()
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called every so often (every 5 minutes actually) to refresh the
|
||||||
|
* tiles so the user gets the correct information.
|
||||||
|
*/
|
||||||
|
def poll() {
|
||||||
|
return boseRefreshNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins this speaker into the everywhere zone
|
||||||
|
*/
|
||||||
|
def boseZoneJoin() {
|
||||||
|
def results = []
|
||||||
|
def posts = parent.boseZoneJoin(this)
|
||||||
|
|
||||||
|
for (post in posts) {
|
||||||
|
if (post['endpoint'])
|
||||||
|
results << bosePOST(post['endpoint'], post['body'], post['host'])
|
||||||
|
}
|
||||||
|
sendEvent(name:"everywhere", value:"leave")
|
||||||
|
results << boseRefreshNowPlaying()
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes this speaker from the everywhere zone
|
||||||
|
*/
|
||||||
|
def boseZoneLeave() {
|
||||||
|
def results = []
|
||||||
|
def posts = parent.boseZoneLeave(this)
|
||||||
|
|
||||||
|
for (post in posts) {
|
||||||
|
if (post['endpoint'])
|
||||||
|
results << bosePOST(post['endpoint'], post['body'], post['host'])
|
||||||
|
}
|
||||||
|
sendEvent(name:"everywhere", value:"join")
|
||||||
|
results << boseRefreshNowPlaying()
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes this speaker and any children WITHOUT
|
||||||
|
* signaling the speakers themselves. This is needed
|
||||||
|
* in certain cases where we know the user action will
|
||||||
|
* cause the zone to collapse (for example, AUX)
|
||||||
|
*/
|
||||||
|
def boseZoneReset() {
|
||||||
|
parent.boseZoneReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles <nowPlaying></nowPlaying> information and can also
|
||||||
|
* perform addtional actions if there is a pending command
|
||||||
|
* stored in the state variable. For example, the power is
|
||||||
|
* handled this way.
|
||||||
|
*
|
||||||
|
* @param xmlData Data to parse
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseParseNowPlaying(xmlData) {
|
||||||
|
def result = []
|
||||||
|
|
||||||
|
// Perform display update, allow it to add additional commands
|
||||||
|
if (boseSetNowPlaying(xmlData)) {
|
||||||
|
result << boseRefreshNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses volume data
|
||||||
|
*
|
||||||
|
* @param xmlData Data to parse
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseParseVolume(xmlData) {
|
||||||
|
def result = []
|
||||||
|
|
||||||
|
sendEvent(name:"volume", value:xmlData.actualvolume.text())
|
||||||
|
sendEvent(name:"mute", value:(Boolean.toBoolean(xmlData.muteenabled.text()) ? "unmuted" : "muted"))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the result of the boseGetEverywhereState() call
|
||||||
|
*
|
||||||
|
* @param xmlData
|
||||||
|
*/
|
||||||
|
def boseParseEverywhere(xmlData) {
|
||||||
|
// No good way of detecting the correct state right now
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses presets and updates the buttons
|
||||||
|
*
|
||||||
|
* @param xmlData Data to parse
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseParsePresets(xmlData) {
|
||||||
|
def result = []
|
||||||
|
|
||||||
|
state.preset = [:]
|
||||||
|
|
||||||
|
def missing = ["1", "2", "3", "4", "5", "6"]
|
||||||
|
for (preset in xmlData.preset) {
|
||||||
|
def id = preset.attributes()['id']
|
||||||
|
def name = preset.ContentItem.itemName[0].text().replaceAll(~/ +/, "\n")
|
||||||
|
if (name == "##TRANS_SONGS##")
|
||||||
|
name = "Local\nPlaylist"
|
||||||
|
sendEvent(name:"station${id}", value:name)
|
||||||
|
missing = missing.findAll { it -> it != id }
|
||||||
|
|
||||||
|
// Store the presets into the state for recall later
|
||||||
|
state.preset["$id"] = XmlUtil.serialize(preset.ContentItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id in missing) {
|
||||||
|
state.preset["$id"] = null
|
||||||
|
sendEvent(name:"station${id}", value:"Preset $id\n\nNot set")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Based on <nowPlaying></nowPlaying>, updates the visual
|
||||||
|
* representation of the speaker
|
||||||
|
*
|
||||||
|
* @param xmlData The nowPlaying info
|
||||||
|
* @param override Provide the source type manually (optional)
|
||||||
|
*
|
||||||
|
* @return true if it would prefer a refresh soon
|
||||||
|
*/
|
||||||
|
def boseSetNowPlaying(xmlData, override=null) {
|
||||||
|
def needrefresh = false
|
||||||
|
def nowplaying = null
|
||||||
|
|
||||||
|
if (xmlData && xmlData.playStatus) {
|
||||||
|
switch(xmlData.playStatus) {
|
||||||
|
case "BUFFERING_STATE":
|
||||||
|
nowplaying = "Please wait\nBuffering..."
|
||||||
|
needrefresh = true
|
||||||
|
break
|
||||||
|
case "PLAY_STATE":
|
||||||
|
sendEvent(name:"playpause", value:"play")
|
||||||
|
break
|
||||||
|
case "PAUSE_STATE":
|
||||||
|
case "STOP_STATE":
|
||||||
|
sendEvent(name:"playpause", value:"pause")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the previous section didn't handle this, take another stab at it
|
||||||
|
if (!nowplaying) {
|
||||||
|
nowplaying = ""
|
||||||
|
switch (override ? override : xmlData.attributes()['source']) {
|
||||||
|
case "AUX":
|
||||||
|
nowplaying = "Auxiliary Input"
|
||||||
|
break
|
||||||
|
case "AIRPLAY":
|
||||||
|
nowplaying = "Air Play"
|
||||||
|
break
|
||||||
|
case "STANDBY":
|
||||||
|
nowplaying = "Standby"
|
||||||
|
break
|
||||||
|
case "INTERNET_RADIO":
|
||||||
|
nowplaying = "${xmlData.stationName.text()}\n\n${xmlData.description.text()}"
|
||||||
|
break
|
||||||
|
case "REFRESH":
|
||||||
|
nowplaying = "Please wait"
|
||||||
|
break
|
||||||
|
case "SPOTIFY":
|
||||||
|
case "DEEZER":
|
||||||
|
case "PANDORA":
|
||||||
|
case "IHEART":
|
||||||
|
if (xmlData.ContentItem.itemName[0])
|
||||||
|
nowplaying += "[${xmlData.ContentItem.itemName[0].text()}]\n\n"
|
||||||
|
case "STORED_MUSIC":
|
||||||
|
nowplaying += "${xmlData.track.text()}"
|
||||||
|
if (xmlData.artist)
|
||||||
|
nowplaying += "\nby\n${xmlData.artist.text()}"
|
||||||
|
if (xmlData.album)
|
||||||
|
nowplaying += "\n\n(${xmlData.album.text()})"
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
if (xmlData != null)
|
||||||
|
nowplaying = "${xmlData.ContentItem.itemName[0].text()}"
|
||||||
|
else
|
||||||
|
nowplaying = "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some last parsing which only deals with actual data from device
|
||||||
|
if (xmlData) {
|
||||||
|
if (xmlData.attributes()['source'] == "STANDBY") {
|
||||||
|
log.trace "nowPlaying reports standby: " + XmlUtil.serialize(xmlData)
|
||||||
|
sendEvent(name:"switch", value:"off")
|
||||||
|
} else {
|
||||||
|
sendEvent(name:"switch", value:"on")
|
||||||
|
}
|
||||||
|
boseSetPlayerAttributes(xmlData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not allow a standby device or AUX to be master
|
||||||
|
if (!parent.boseZoneHasMaster() && (override ? override : xmlData.attributes()['source']) == "STANDBY")
|
||||||
|
sendEvent(name:"everywhere", value:"unavailable")
|
||||||
|
else if ((override ? override : xmlData.attributes()['source']) == "AUX")
|
||||||
|
sendEvent(name:"everywhere", value:"unavailable")
|
||||||
|
else if (boseGetZone()) {
|
||||||
|
log.info "We're in the zone: " + boseGetZone()
|
||||||
|
sendEvent(name:"everywhere", value:"leave")
|
||||||
|
} else
|
||||||
|
sendEvent(name:"everywhere", value:"join")
|
||||||
|
|
||||||
|
sendEvent(name:"nowplaying", value:nowplaying)
|
||||||
|
|
||||||
|
return needrefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the attributes exposed by the music Player capability
|
||||||
|
*
|
||||||
|
* @param xmlData The NowPlaying XML data
|
||||||
|
*/
|
||||||
|
def boseSetPlayerAttributes(xmlData) {
|
||||||
|
// Refresh attributes
|
||||||
|
def trackText = ""
|
||||||
|
def trackDesc = ""
|
||||||
|
def trackData = [:]
|
||||||
|
|
||||||
|
switch (xmlData.attributes()['source']) {
|
||||||
|
case "STANDBY":
|
||||||
|
trackData["station"] = trackText = trackDesc = "Standby"
|
||||||
|
break
|
||||||
|
case "AUX":
|
||||||
|
trackData["station"] = trackText = trackDesc = "Auxiliary Input"
|
||||||
|
break
|
||||||
|
case "AIRPLAY":
|
||||||
|
trackData["station"] = trackText = trackDesc = "Air Play"
|
||||||
|
break
|
||||||
|
case "SPOTIFY":
|
||||||
|
case "DEEZER":
|
||||||
|
case "PANDORA":
|
||||||
|
case "IHEART":
|
||||||
|
case "STORED_MUSIC":
|
||||||
|
trackText = trackDesc = "${xmlData.track.text()}"
|
||||||
|
trackData["name"] = xmlData.track.text()
|
||||||
|
if (xmlData.artist) {
|
||||||
|
trackText += " by ${xmlData.artist.text()}"
|
||||||
|
trackDesc += " - ${xmlData.artist.text()}"
|
||||||
|
trackData["artist"] = xmlData.artist.text()
|
||||||
|
}
|
||||||
|
if (xmlData.album) {
|
||||||
|
trackText += " (${xmlData.album.text()})"
|
||||||
|
trackData["album"] = xmlData.album.text()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "INTERNET_RADIO":
|
||||||
|
trackDesc = xmlData.stationName.text()
|
||||||
|
trackText = xmlData.stationName.text() + ": " + xmlData.description.text()
|
||||||
|
trackData["station"] = xmlData.stationName.text()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
trackText = trackDesc = xmlData.ContentItem.itemName[0].text()
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent(name:"trackDescription", value:trackDesc, descriptionText:trackText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries the state of the "play everywhere" mode
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseGetEverywhereState() {
|
||||||
|
return boseGET("/getZone")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a remote key event
|
||||||
|
*
|
||||||
|
* @param key The name of the key
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*
|
||||||
|
* @note It's VITAL that it's done as two requests, or it will ignore the
|
||||||
|
* the second key info.
|
||||||
|
*/
|
||||||
|
def boseKeypress(key) {
|
||||||
|
def press = "<key state=\"press\" sender=\"Gabbo\">${key}</key>"
|
||||||
|
def release = "<key state=\"release\" sender=\"Gabbo\">${key}</key>"
|
||||||
|
|
||||||
|
return [bosePOST("/key", press), bosePOST("/key", release)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses or plays current preset
|
||||||
|
*
|
||||||
|
* @param play If true, plays, else it pauses (depending on preset, may stop)
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseSetPlayMode(boolean play) {
|
||||||
|
log.trace "Sending " + (play ? "PLAY" : "PAUSE")
|
||||||
|
return boseKeypress(play ? "PLAY" : "PAUSE")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the volume in a deterministic way.
|
||||||
|
*
|
||||||
|
* @param New volume level, ranging from 0 to 100
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseSetVolume(int level) {
|
||||||
|
def result = []
|
||||||
|
int vol = Math.min(100, Math.max(level, 0))
|
||||||
|
|
||||||
|
sendEvent(name:"volume", value:"${vol}")
|
||||||
|
|
||||||
|
return [bosePOST("/volume", "<volume>${vol}</volume>"), boseGetVolume()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the mute state, unfortunately, for now, we need to query current
|
||||||
|
* state before taking action (no discrete mute/unmute)
|
||||||
|
*
|
||||||
|
* @param mute If true, mutes the system
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseSetMute(boolean mute) {
|
||||||
|
queueCallback('volume', 'cb_boseSetMute', mute ? 'MUTE' : 'UNMUTE')
|
||||||
|
return boseGetVolume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for boseSetMute(), checks current state and changes it
|
||||||
|
* if it doesn't match the requested state.
|
||||||
|
*
|
||||||
|
* @param xml The volume XML data
|
||||||
|
* @param mute The new state of mute
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def cb_boseSetMute(xml, mute) {
|
||||||
|
def result = []
|
||||||
|
if ((xml.muteenabled.text() == 'false' && mute == 'MUTE') ||
|
||||||
|
(xml.muteenabled.text() == 'true' && mute == 'UNMUTE'))
|
||||||
|
{
|
||||||
|
result << boseKeypress("MUTE")
|
||||||
|
}
|
||||||
|
log.trace("muteunmute: " + ((mute == "MUTE") ? "unmute" : "mute"))
|
||||||
|
sendEvent(name:"muteunmute", value:((mute == "MUTE") ? "unmute" : "mute"))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the state of the volume
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseGetVolume() {
|
||||||
|
return boseGET("/volume")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the track to either the previous or next
|
||||||
|
*
|
||||||
|
* @param direction > 0 = next track, < 0 = previous track, 0 = no action
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseChangeTrack(int direction) {
|
||||||
|
if (direction < 0) {
|
||||||
|
return boseKeypress("PREV_TRACK")
|
||||||
|
} else if (direction > 0) {
|
||||||
|
return boseKeypress("NEXT_TRACK")
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the input to preset 1-6 or AUX
|
||||||
|
*
|
||||||
|
* @param input The input (one of 1,2,3,4,5,6,aux)
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*
|
||||||
|
* @note If no presets have been loaded, it will first refresh the presets.
|
||||||
|
*/
|
||||||
|
def boseSetInput(input) {
|
||||||
|
log.info "boseSetInput(${input})"
|
||||||
|
def result = []
|
||||||
|
|
||||||
|
if (!state.preset) {
|
||||||
|
result << boseGetPresets()
|
||||||
|
queueCallback('presets', 'cb_boseSetInput', input)
|
||||||
|
} else {
|
||||||
|
result << cb_boseSetInput(null, input)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback used by boseSetInput(), either called directly by
|
||||||
|
* boseSetInput() if we already have presets, or called after
|
||||||
|
* retreiving the presets for the first time.
|
||||||
|
*
|
||||||
|
* @param xml The presets XML data
|
||||||
|
* @param input Desired input
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*
|
||||||
|
* @note Uses KEY commands for AUX, otherwise /select endpoint.
|
||||||
|
* Reason for this is latency. Since keypresses are done
|
||||||
|
* in pairs (press + release), you could accidentally change
|
||||||
|
* the preset if there is a long delay between the two.
|
||||||
|
*/
|
||||||
|
def cb_boseSetInput(xml, input) {
|
||||||
|
def result = []
|
||||||
|
|
||||||
|
if (input >= "1" && input <= "6" && state.preset["$input"])
|
||||||
|
result << bosePOST("/select", state.preset["$input"])
|
||||||
|
else if (input.toLowerCase() == "aux") {
|
||||||
|
result << boseKeypress("AUX_INPUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horrible workaround... but we need to delay
|
||||||
|
// the update by at least a few seconds...
|
||||||
|
result << boseRefreshNowPlaying(3000)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the power state of the bose unit
|
||||||
|
*
|
||||||
|
* @param device The device in-question
|
||||||
|
* @param enable True to power on, false to power off
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*
|
||||||
|
* @note Will first query state before acting since there
|
||||||
|
* is no discreete call.
|
||||||
|
*/
|
||||||
|
def boseSetPowerState(boolean enable) {
|
||||||
|
log.info "boseSetPowerState(${enable})"
|
||||||
|
queueCallback('nowPlaying', "cb_boseSetPowerState", enable ? "POWERON" : "POWEROFF")
|
||||||
|
return boseRefreshNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback function used by boseSetPowerState(), is used
|
||||||
|
* to handle the fact that we only have a toggle for power.
|
||||||
|
*
|
||||||
|
* @param xml The XML data from nowPlaying
|
||||||
|
* @param state The requested state
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def cb_boseSetPowerState(xml, state) {
|
||||||
|
def result = []
|
||||||
|
if ( (xml.attributes()['source'] == "STANDBY" && state == "POWERON") ||
|
||||||
|
(xml.attributes()['source'] != "STANDBY" && state == "POWEROFF") )
|
||||||
|
{
|
||||||
|
result << boseKeypress("POWER")
|
||||||
|
if (state == "POWERON") {
|
||||||
|
result << boseRefreshNowPlaying()
|
||||||
|
queueCallback('nowPlaying', "cb_boseConfirmPowerOn", 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We're sometimes too quick on the draw and get a refreshed nowPlaying
|
||||||
|
* which shows standby (essentially, the device has yet to completely
|
||||||
|
* transition to awake state), so we need to poll a few times extra
|
||||||
|
* to make sure we get it right.
|
||||||
|
*
|
||||||
|
* @param xml The XML data from nowPlaying
|
||||||
|
* @param tries A counter which will decrease, once it reaches zero,
|
||||||
|
* we give up and assume that whatever we got was correct.
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def cb_boseConfirmPowerOn(xml, tries) {
|
||||||
|
def result = []
|
||||||
|
log.warn "boseConfirmPowerOn() attempt #" + tries
|
||||||
|
if (xml.attributes()['source'] == "STANDBY" && tries > 0) {
|
||||||
|
result << boseRefreshNowPlaying()
|
||||||
|
queueCallback('nowPlaying', "cb_boseConfirmPowerOn", tries-1)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests an update on currently playing item(s)
|
||||||
|
*
|
||||||
|
* @param delay If set to non-zero, delays x ms before issuing
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseRefreshNowPlaying(delay=0) {
|
||||||
|
if (delay > 0) {
|
||||||
|
return ["delay ${delay}", boseGET("/now_playing")]
|
||||||
|
}
|
||||||
|
return boseGET("/now_playing")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests the list of presets
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseGetPresets() {
|
||||||
|
return boseGET("/presets")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function, makes GET requests to BOSE device
|
||||||
|
*
|
||||||
|
* @param path What endpoint
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def boseGET(String path) {
|
||||||
|
new physicalgraph.device.HubAction([
|
||||||
|
method: "GET",
|
||||||
|
path: path,
|
||||||
|
headers: [
|
||||||
|
HOST: state.address + ":8090",
|
||||||
|
]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function, makes a POST request to the BOSE device with
|
||||||
|
* the provided data.
|
||||||
|
*
|
||||||
|
* @param path What endpoint
|
||||||
|
* @param data What data
|
||||||
|
* @param address Specific ip and port (optional)
|
||||||
|
*
|
||||||
|
* @return command
|
||||||
|
*/
|
||||||
|
def bosePOST(String path, String data, String address=null) {
|
||||||
|
new physicalgraph.device.HubAction([
|
||||||
|
method: "POST",
|
||||||
|
path: path,
|
||||||
|
body: data,
|
||||||
|
headers: [
|
||||||
|
HOST: address ?: (state.address + ":8090"),
|
||||||
|
]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a callback function for when a specific XML root is received
|
||||||
|
* Will execute on subsequent parse() call(s), never on the current
|
||||||
|
* parse() call.
|
||||||
|
*
|
||||||
|
* @param root The root node that this callback should react to
|
||||||
|
* @param func Name of the function
|
||||||
|
* @param param Parameters for function (optional)
|
||||||
|
*/
|
||||||
|
def queueCallback(String root, String func, param=null) {
|
||||||
|
if (!state.pending)
|
||||||
|
state.pending = [:]
|
||||||
|
if (!state.pending[root])
|
||||||
|
state.pending[root] = []
|
||||||
|
state.pending[root] << ["$func":"$param"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers the pending callbacks into readiness state
|
||||||
|
* so they can be executed by processCallbacks()
|
||||||
|
*
|
||||||
|
* This is needed to avoid reacting to queueCallbacks() within
|
||||||
|
* the same loop.
|
||||||
|
*/
|
||||||
|
def prepareCallbacks() {
|
||||||
|
if (!state.pending)
|
||||||
|
return
|
||||||
|
if (!state.ready)
|
||||||
|
state.ready = [:]
|
||||||
|
state.ready << state.pending
|
||||||
|
state.pending = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes any ready callback for a specific root node
|
||||||
|
* with associated parameter and then clears that entry.
|
||||||
|
*
|
||||||
|
* If a callback returns data, it's added to a list of
|
||||||
|
* commands which is returned to the caller of this function
|
||||||
|
*
|
||||||
|
* Once a callback has been used, it's removed from the list
|
||||||
|
* of queued callbacks (ie, it executes only once!)
|
||||||
|
*
|
||||||
|
* @param xml The XML data to be examined and delegated
|
||||||
|
* @return list of commands
|
||||||
|
*/
|
||||||
|
def processCallbacks(xml) {
|
||||||
|
def result = []
|
||||||
|
|
||||||
|
if (!state.ready)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if (state.ready[xml.name()]) {
|
||||||
|
state.ready[xml.name()].each { callback ->
|
||||||
|
callback.each { func, param ->
|
||||||
|
if (func != "func") {
|
||||||
|
if (param)
|
||||||
|
result << "$func"(xml, param)
|
||||||
|
else
|
||||||
|
result << "$func"(xml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.ready.remove(xml.name())
|
||||||
|
}
|
||||||
|
return result.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State managament for the Play Everywhere zone.
|
||||||
|
* This is typically called from the parent.
|
||||||
|
*
|
||||||
|
* A device is either:
|
||||||
|
*
|
||||||
|
* null = Not participating
|
||||||
|
* server = running the show
|
||||||
|
* client = under the control of the server
|
||||||
|
*
|
||||||
|
* @param newstate (see above for types)
|
||||||
|
*/
|
||||||
|
def boseSetZone(String newstate) {
|
||||||
|
log.debug "boseSetZone($newstate)"
|
||||||
|
state.zone = newstate
|
||||||
|
|
||||||
|
// Refresh our state
|
||||||
|
if (newstate) {
|
||||||
|
sendEvent(name:"everywhere", value:"leave")
|
||||||
|
} else {
|
||||||
|
sendEvent(name:"everywhere", value:"join")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by the Everywhere zone, returns the current state
|
||||||
|
* of zone membership (null, server, client)
|
||||||
|
* This is typically called from the parent.
|
||||||
|
*
|
||||||
|
* @return state
|
||||||
|
*/
|
||||||
|
def boseGetZone() {
|
||||||
|
return state.zone
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the DeviceID of this particular device.
|
||||||
|
*
|
||||||
|
* Needs to be done this way since DNI is not always
|
||||||
|
* the same as DeviceID which is used internally by
|
||||||
|
* BOSE.
|
||||||
|
*
|
||||||
|
* @param devID The DeviceID
|
||||||
|
*/
|
||||||
|
def boseSetDeviceID(String devID) {
|
||||||
|
state.deviceID = devID
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the DeviceID for this device
|
||||||
|
*
|
||||||
|
* @return deviceID
|
||||||
|
*/
|
||||||
|
def boseGetDeviceID() {
|
||||||
|
return state.deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the IP of this device
|
||||||
|
*
|
||||||
|
* @return IP address
|
||||||
|
*/
|
||||||
|
def getDeviceIP() {
|
||||||
|
return parent.resolveDNI2Address(device.deviceNetworkId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
/**
|
||||||
|
* Fidure Thermostat, Based on ZigBee thermostat (SmartThings)
|
||||||
|
*
|
||||||
|
* Author: Fidure
|
||||||
|
* Date: 2014-12-13
|
||||||
|
* Updated: 2015-08-26
|
||||||
|
*/
|
||||||
|
metadata {
|
||||||
|
// Automatically generated. Make future change here.
|
||||||
|
definition (name: "Fidure Thermostat", namespace: "smartthings", author: "SmartThings") {
|
||||||
|
|
||||||
|
capability "Actuator"
|
||||||
|
capability "Temperature Measurement"
|
||||||
|
capability "Thermostat"
|
||||||
|
capability "Configuration"
|
||||||
|
capability "Refresh"
|
||||||
|
capability "Sensor"
|
||||||
|
capability "Polling"
|
||||||
|
|
||||||
|
attribute "displayTemperature","number"
|
||||||
|
attribute "displaySetpoint", "string"
|
||||||
|
command "raiseSetpoint"
|
||||||
|
command "lowerSetpoint"
|
||||||
|
attribute "upButtonState", "string"
|
||||||
|
attribute "downButtonState", "string"
|
||||||
|
|
||||||
|
attribute "runningMode", "string"
|
||||||
|
attribute "lockLevel", "string"
|
||||||
|
|
||||||
|
command "setThermostatTime"
|
||||||
|
command "lock"
|
||||||
|
|
||||||
|
attribute "prorgammingOperation", "number"
|
||||||
|
attribute "prorgammingOperationDisplay", "string"
|
||||||
|
command "Program"
|
||||||
|
|
||||||
|
attribute "setpointHold", "string"
|
||||||
|
attribute "setpointHoldDisplay", "string"
|
||||||
|
command "Hold"
|
||||||
|
attribute "holdExpiary", "string"
|
||||||
|
|
||||||
|
attribute "lastTimeSync", "string"
|
||||||
|
|
||||||
|
attribute "thermostatOperatingState", "string"
|
||||||
|
|
||||||
|
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0201,0204,0B05", outClusters: "000A, 0019"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// simulator metadata
|
||||||
|
simulator { }
|
||||||
|
// pref
|
||||||
|
preferences {
|
||||||
|
|
||||||
|
input ("hold_time", "enum", title: "Default Hold Time in Hours",
|
||||||
|
description: "Default Hold Duration in hours",
|
||||||
|
range: "1..24", options: ["No Hold", "2 Hours", "4 Hours", "8 Hours", "12 Hours", "1 Day"],
|
||||||
|
displayDuringSetup: false)
|
||||||
|
input ("sync_clock", "boolean", title: "Synchronize Thermostat Clock Automatically?", options: ["Yes","No"])
|
||||||
|
input ("lock_level", "enum", title: "Thermostat Screen Lock Level", options: ["Full","Mode Only", "Setpoint"])
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles {
|
||||||
|
valueTile("temperature", "displayTemperature", width: 2, height: 2) {
|
||||||
|
state("temperature", label:'${currentValue}°', unit:"F",
|
||||||
|
backgroundColors:[
|
||||||
|
[value: 0, color: "#153591"],
|
||||||
|
[value: 7, color: "#1e9cbb"],
|
||||||
|
[value: 15, color: "#90d2a7"],
|
||||||
|
[value: 23, color: "#44b621"],
|
||||||
|
[value: 29, color: "#f1d801"],
|
||||||
|
[value: 35, color: "#d04e00"],
|
||||||
|
[value: 36, color: "#bc2323"],
|
||||||
|
// fahrenheit range
|
||||||
|
[value: 37, color: "#153591"],
|
||||||
|
[value: 44, color: "#1e9cbb"],
|
||||||
|
[value: 59, color: "#90d2a7"],
|
||||||
|
[value: 74, color: "#44b621"],
|
||||||
|
[value: 84, color: "#f1d801"],
|
||||||
|
[value: 95, color: "#d04e00"],
|
||||||
|
[value: 96, color: "#bc2323"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
standardTile("mode", "device.thermostatMode", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "off", action:"thermostat.setThermostatMode", icon:"st.thermostat.heating-cooling-off"
|
||||||
|
state "cool", action:"thermostat.setThermostatMode", icon:"st.thermostat.cool"
|
||||||
|
state "heat", action:"thermostat.setThermostatMode", icon:"st.thermostat.heat"
|
||||||
|
state "auto", action:"thermostat.setThermostatMode", icon:"st.thermostat.auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("fanMode", "device.thermostatFanMode", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "fanAuto", label:'${name}', action:"thermostat.setThermostatFanMode"
|
||||||
|
state "fanOn", label:'${name}', action:"thermostat.setThermostatFanMode"
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("hvacStatus", "thermostatOperatingState", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "Resting", label: 'Resting'
|
||||||
|
state "Heating", icon:"st.thermostat.heating"
|
||||||
|
state "Cooling", icon:"st.thermostat.cooling"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
standardTile("lock", "lockLevel", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "Unlocked", action:"lock", label:'${name}'
|
||||||
|
state "Mode Only", action:"lock", label:'${name}'
|
||||||
|
state "Setpoint", action:"lock", label:'${name}'
|
||||||
|
state "Full", action:"lock", label:'${name}'
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("heatSliderControl", "device.heatingSetpoint", "slider", height: 1, width: 2, inactiveLabel: false, range: "$min..$max") {
|
||||||
|
state "setHeatingSetpoint", action:"thermostat.setHeatingSetpoint", backgroundColor:"#d04e00"
|
||||||
|
}
|
||||||
|
valueTile("heatingSetpoint", "device.heatingSetpoint", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "heat", label:'${currentValue}° heat', unit:"F", backgroundColor:"#ffffff"
|
||||||
|
}
|
||||||
|
controlTile("coolSliderControl", "device.coolingSetpoint", "slider", height: 1, width: 2, inactiveLabel: false, range: "$min..$max") {
|
||||||
|
state "setCoolingSetpoint", action:"thermostat.setCoolingSetpoint", backgroundColor: "#1e9cbb"
|
||||||
|
}
|
||||||
|
valueTile("coolingSetpoint", "device.coolingSetpoint", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "cool", label:'${currentValue}° cool', unit:"F", backgroundColor:"#ffffff"
|
||||||
|
}
|
||||||
|
standardTile("refresh", "device.temperature", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
|
||||||
|
}
|
||||||
|
standardTile("configure", "device.configure", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure"
|
||||||
|
}
|
||||||
|
|
||||||
|
valueTile("scheduleText", "prorgammingOperation", inactiveLabel: false, decoration: "flat", width: 2) {
|
||||||
|
state "default", label: 'Schedule'
|
||||||
|
}
|
||||||
|
valueTile("schedule", "prorgammingOperationDisplay", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "default", action:"Program", label: '${currentValue}'
|
||||||
|
}
|
||||||
|
|
||||||
|
valueTile("hold", "setpointHoldDisplay", inactiveLabel: false, decoration: "flat", width: 3) {
|
||||||
|
state "setpointHold", action:"Hold", label: '${currentValue}'
|
||||||
|
}
|
||||||
|
|
||||||
|
valueTile("setpoint", "displaySetpoint", width: 2, height: 2)
|
||||||
|
{
|
||||||
|
state("displaySetpoint", label: '${currentValue}°',
|
||||||
|
backgroundColor: "#919191")
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("upButton", "upButtonState", decoration: "flat", inactiveLabel: false) {
|
||||||
|
state "normal", action:"raiseSetpoint", backgroundColor:"#919191", icon:"st.thermostat.thermostat-up"
|
||||||
|
state "pressed", action:"raiseSetpoint", backgroundColor:"#ff0000", icon:"st.thermostat.thermostat-up"
|
||||||
|
}
|
||||||
|
standardTile("downButton", "downButtonState", decoration: "flat", inactiveLabel: false) {
|
||||||
|
state "normal", action:"lowerSetpoint", backgroundColor:"#919191", icon:"st.thermostat.thermostat-down"
|
||||||
|
state "pressed", action:"lowerSetpoint", backgroundColor:"#ff9191", icon:"st.thermostat.thermostat-down"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
main "temperature"
|
||||||
|
details([ "temperature", "mode", "hvacStatus","setpoint","upButton","downButton","scheduleText", "schedule", "hold",
|
||||||
|
"heatSliderControl", "heatingSetpoint","coolSliderControl", "coolingSetpoint", "lock", "refresh", "configure"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def getMin() {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (getTemperatureScale() == "C")
|
||||||
|
return 10
|
||||||
|
else
|
||||||
|
return 50
|
||||||
|
} catch (all)
|
||||||
|
{
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def getMax() {
|
||||||
|
try {
|
||||||
|
if (getTemperatureScale() == "C")
|
||||||
|
return 30
|
||||||
|
else
|
||||||
|
return 86
|
||||||
|
} catch (all)
|
||||||
|
{
|
||||||
|
return 86
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse events into attributes
|
||||||
|
def parse(String description) {
|
||||||
|
log.debug "Parse description $description"
|
||||||
|
def result = []
|
||||||
|
|
||||||
|
if (description?.startsWith("read attr -")) {
|
||||||
|
|
||||||
|
//TODO: Parse RAW strings for multiple attributes
|
||||||
|
def descMap = parseDescriptionAsMap(description)
|
||||||
|
log.debug "Desc Map: $descMap"
|
||||||
|
for ( atMap in descMap.attrs)
|
||||||
|
{
|
||||||
|
def map = [:]
|
||||||
|
|
||||||
|
if (descMap.cluster == "0201")
|
||||||
|
{
|
||||||
|
//log.trace "attribute: ${atMap.attrId} "
|
||||||
|
switch(atMap.attrId.toLowerCase())
|
||||||
|
{
|
||||||
|
case "0000":
|
||||||
|
map.name = "temperature"
|
||||||
|
map.value = getTemperature(atMap.value)
|
||||||
|
result += createEvent("name":"displayTemperature", "value": getDisplayTemperature(atMap.value))
|
||||||
|
break;
|
||||||
|
case "0005":
|
||||||
|
//log.debug "hex time: ${descMap.value}"
|
||||||
|
if (atMap.encoding == "23")
|
||||||
|
{
|
||||||
|
map.name = "holdExpiary"
|
||||||
|
map.value = "${convertToTime(atMap.value).getTime()}"
|
||||||
|
//log.trace "HOLD EXPIRY: ${atMap.value} is ${map.value}"
|
||||||
|
updateHoldLabel("HoldExp", "${map.value}")
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "0011":
|
||||||
|
map.name = "coolingSetpoint"
|
||||||
|
map.value = getDisplayTemperature(atMap.value)
|
||||||
|
updateSetpoint(map.name,map.value)
|
||||||
|
break;
|
||||||
|
case "0012":
|
||||||
|
map.name = "heatingSetpoint"
|
||||||
|
map.value = getDisplayTemperature(atMap.value)
|
||||||
|
updateSetpoint(map.name,map.value)
|
||||||
|
break;
|
||||||
|
case "001c":
|
||||||
|
map.name = "thermostatMode"
|
||||||
|
map.value = getModeMap()[atMap.value]
|
||||||
|
updateSetpoint(map.name,map.value)
|
||||||
|
break;
|
||||||
|
case "001e": //running mode enum8
|
||||||
|
map.name = "runningMode"
|
||||||
|
map.value = getModeMap()[atMap.value]
|
||||||
|
updateSetpoint(map.name,map.value)
|
||||||
|
break;
|
||||||
|
case "0023": // setpoint hold enum8
|
||||||
|
map.name = "setpointHold"
|
||||||
|
map.value = getHoldMap()[atMap.value]
|
||||||
|
updateHoldLabel("Hold", map.value)
|
||||||
|
break;
|
||||||
|
case "0024": // hold duration int16u
|
||||||
|
map.name = "setpointHoldDuration"
|
||||||
|
map.value = Integer.parseInt("${atMap.value}", 16)
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "0025": // thermostat programming operation bitmap8
|
||||||
|
map.name = "prorgammingOperation"
|
||||||
|
def val = getProgrammingMap()[Integer.parseInt("${atMap.value}", 16) & 0x01]
|
||||||
|
result += createEvent("name":"prorgammingOperationDisplay", "value": val)
|
||||||
|
map.value = atMap.value
|
||||||
|
break;
|
||||||
|
case "0029":
|
||||||
|
// relay state
|
||||||
|
map.name = "thermostatOperatingState"
|
||||||
|
map.value = getThermostatOperatingState(atMap.value)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (descMap.cluster == "0204")
|
||||||
|
{
|
||||||
|
if (atMap.attrId == "0001")
|
||||||
|
{
|
||||||
|
map.name = "lockLevel"
|
||||||
|
map.value = getLockMap()[atMap.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
result += createEvent(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug "Parse returned $result"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
def parseDescriptionAsMap(description) {
|
||||||
|
def map = (description - "read attr - ").split(",").inject([:]) { map, param ->
|
||||||
|
def nameAndValue = param.split(":")
|
||||||
|
map += [(nameAndValue[0].trim()):nameAndValue[1].trim()]
|
||||||
|
}
|
||||||
|
|
||||||
|
def attrId = map.get('attrId')
|
||||||
|
def encoding = map.get('encoding')
|
||||||
|
def value = map.get('value')
|
||||||
|
def result = map.get('result')
|
||||||
|
def list = [];
|
||||||
|
|
||||||
|
if (getDataLengthByType(map.get('encoding')) < map.get('value').length()) {
|
||||||
|
def raw = map.get('raw')
|
||||||
|
|
||||||
|
def size = Long.parseLong(''+ map.get('size'), 16)
|
||||||
|
def index = 12;
|
||||||
|
def len
|
||||||
|
|
||||||
|
//log.trace "processing multi attributes"
|
||||||
|
while((index-12) < size) {
|
||||||
|
attrId = flipHexStringEndianness(raw[index..(index+3)])
|
||||||
|
index+= 4;
|
||||||
|
if (result == "success")
|
||||||
|
index+=2;
|
||||||
|
encoding = raw[index..(index+1)]
|
||||||
|
index+= 2;
|
||||||
|
len =getDataLengthByType(encoding)
|
||||||
|
value = flipHexStringEndianness(raw[index..(index+len-1)])
|
||||||
|
index+=len;
|
||||||
|
list += ['attrId': "$attrId", 'encoding':"$encoding", 'value': "$value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
list += ['attrId': "$attrId", 'encoding': "$encoding", 'value': "$value"]
|
||||||
|
|
||||||
|
map.remove('value')
|
||||||
|
map.remove('encoding')
|
||||||
|
map.remove('attrId')
|
||||||
|
map += ['attrs' : list ]
|
||||||
|
}
|
||||||
|
|
||||||
|
def flipHexStringEndianness(s)
|
||||||
|
{
|
||||||
|
s = s.reverse()
|
||||||
|
def sb = new StringBuilder()
|
||||||
|
for (int i=0; i < s.length() -1; i+=2)
|
||||||
|
sb.append(s.charAt(i+1)).append(s.charAt(i))
|
||||||
|
sb
|
||||||
|
}
|
||||||
|
|
||||||
|
def getDataLengthByType(t)
|
||||||
|
{
|
||||||
|
// number of bytes in each static data type
|
||||||
|
def map = ["08":1, "09":2, "0a":3, "0b":4, "0c":5, "0d":6, "0e":7, "0f":8, "10":1, "18":1, "19":2, "1a":3, "1b":4,
|
||||||
|
"1c":5,"1d":6, "1e":7, "1f":8, "20":1, "21":2, "22":3, "23":4, "24":5, "25":6, "26":7, "27":8, "28":1, "29":2,
|
||||||
|
"2a":3, "2b":4, "2c":5, "2d":6, "2e":7, "2f":8, "30":1, "31":2, "38":2, "39":4, "40":8, "e0":4, "e1":4, "e2":4,
|
||||||
|
"e8":2, "e9":2, "ea":4, "f0":8, "f1":16]
|
||||||
|
|
||||||
|
// return number of hex chars
|
||||||
|
return map.get(t) * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def getProgrammingMap() { [
|
||||||
|
0:"Off",
|
||||||
|
1:"On"
|
||||||
|
]}
|
||||||
|
|
||||||
|
def getModeMap() { [
|
||||||
|
"00":"off",
|
||||||
|
"01":"auto",
|
||||||
|
"03":"cool",
|
||||||
|
"04":"heat"
|
||||||
|
]}
|
||||||
|
|
||||||
|
def getFanModeMap() { [
|
||||||
|
"04":"fanOn",
|
||||||
|
"05":"fanAuto"
|
||||||
|
]}
|
||||||
|
|
||||||
|
def getHoldMap()
|
||||||
|
{[
|
||||||
|
"00":"Off",
|
||||||
|
"01":"On"
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
def updateSetpoint(attrib, val)
|
||||||
|
{
|
||||||
|
def cool = device.currentState("coolingSetpoint")?.value
|
||||||
|
def heat = device.currentState("heatingSetpoint")?.value
|
||||||
|
def runningMode = device.currentState("runningMode")?.value
|
||||||
|
def mode = device.currentState("thermostatMode")?.value
|
||||||
|
|
||||||
|
def value = '--';
|
||||||
|
|
||||||
|
|
||||||
|
if ("heat" == mode && heat != null)
|
||||||
|
value = heat;
|
||||||
|
else if ("cool" == mode && cool != null)
|
||||||
|
value = cool;
|
||||||
|
else if ("auto" == mode && runningMode == "cool" && cool != null)
|
||||||
|
value = cool;
|
||||||
|
else if ("auto" == mode && runningMode == "heat" && heat != null)
|
||||||
|
value = heat;
|
||||||
|
|
||||||
|
sendEvent("name":"displaySetpoint", "value": value)
|
||||||
|
}
|
||||||
|
|
||||||
|
def raiseSetpoint()
|
||||||
|
{
|
||||||
|
sendEvent("name":"upButtonState", "value": "pressed")
|
||||||
|
sendEvent("name":"upButtonState", "value": "normal")
|
||||||
|
adjustSetpoint(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
def lowerSetpoint()
|
||||||
|
{
|
||||||
|
sendEvent("name":"downButtonState", "value": "pressed")
|
||||||
|
sendEvent("name":"downButtonState", "value": "normal")
|
||||||
|
adjustSetpoint(-5)
|
||||||
|
}
|
||||||
|
|
||||||
|
def adjustSetpoint(value)
|
||||||
|
{
|
||||||
|
def runningMode = device.currentState("runningMode")?.value
|
||||||
|
def mode = device.currentState("thermostatMode")?.value
|
||||||
|
|
||||||
|
//default to both heat and cool
|
||||||
|
def modeData = 0x02
|
||||||
|
|
||||||
|
if ("heat" == mode || "heat" == runningMode)
|
||||||
|
modeData = "00"
|
||||||
|
else if ("cool" == mode || "cool" == runningMode)
|
||||||
|
modeData = "01"
|
||||||
|
|
||||||
|
def amountData = String.format("%02X", value)[-2..-1]
|
||||||
|
|
||||||
|
|
||||||
|
"st cmd 0x${device.deviceNetworkId} 1 0x201 0 {" + modeData + " " + amountData + "}"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def getDisplayTemperature(value)
|
||||||
|
{
|
||||||
|
def t = Integer.parseInt("$value", 16);
|
||||||
|
|
||||||
|
|
||||||
|
if (getTemperatureScale() == "C") {
|
||||||
|
t = (((t + 4) / 10) as Integer) / 10;
|
||||||
|
} else {
|
||||||
|
t = ((10 *celsiusToFahrenheit(t/100)) as Integer)/ 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
def updateHoldLabel(attr, value)
|
||||||
|
{
|
||||||
|
def currentHold = (device?.currentState("setpointHold")?.value)?: "..."
|
||||||
|
|
||||||
|
def holdExp = device?.currentState("holdExpiary")?.value
|
||||||
|
holdExp = holdExp?: "${(new Date()).getTime()}"
|
||||||
|
|
||||||
|
if ("Hold" == attr)
|
||||||
|
{
|
||||||
|
currentHold = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("HoldExp" == attr)
|
||||||
|
{
|
||||||
|
holdExp = value
|
||||||
|
}
|
||||||
|
boolean past = ( (new Date(holdExp.toLong()).getTime()) < (new Date().getTime()))
|
||||||
|
|
||||||
|
if ("HoldExp" == attr)
|
||||||
|
{
|
||||||
|
if (!past)
|
||||||
|
currentHold = "On"
|
||||||
|
else
|
||||||
|
currentHold = "Off"
|
||||||
|
}
|
||||||
|
|
||||||
|
def holdString = (currentHold == "On")?
|
||||||
|
( (past)? "Is On" : "Ends ${compareWithNow(holdExp.toLong())}") :
|
||||||
|
((currentHold == "Off")? " is Off" : " ...")
|
||||||
|
|
||||||
|
sendEvent("name":"setpointHoldDisplay", "value": "Hold ${holdString}")
|
||||||
|
}
|
||||||
|
|
||||||
|
def getSetPointHoldDuration()
|
||||||
|
{
|
||||||
|
def holdTime = 0
|
||||||
|
|
||||||
|
if (settings.hold_time?.contains("Hours"))
|
||||||
|
{
|
||||||
|
holdTime = Integer.parseInt(settings.hold_time[0..1].trim())
|
||||||
|
}
|
||||||
|
else if (settings.hold_time?.contains("Day"))
|
||||||
|
{
|
||||||
|
holdTime = Integer.parseInt(settings.hold_time[0..1].trim()) * 24
|
||||||
|
}
|
||||||
|
|
||||||
|
def currentHoldDuration = device.currentState("setpointHoldDuration")?.value
|
||||||
|
|
||||||
|
|
||||||
|
if (Short.parseShort('0'+ (currentHoldDuration?: 0)) != (holdTime * 60))
|
||||||
|
{
|
||||||
|
[
|
||||||
|
"st wattr 0x${device.deviceNetworkId} 1 0x201 0x24 0x21 {" +
|
||||||
|
String.format("%04X", ((holdTime * 60) as Short)) // switch to zigbee endian
|
||||||
|
|
||||||
|
+ "}", "delay 100",
|
||||||
|
"st rattr 0x${device.deviceNetworkId} 1 0x201 0x24", "delay 200",
|
||||||
|
]
|
||||||
|
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def Hold()
|
||||||
|
{
|
||||||
|
def currentHold = device.currentState("setpointHold")?.value
|
||||||
|
|
||||||
|
def next = (currentHold == "On") ? "00" : "01"
|
||||||
|
def nextHold = getHoldMap()[next]
|
||||||
|
|
||||||
|
sendEvent("name":"setpointHold", "value":nextHold)
|
||||||
|
|
||||||
|
// set the duration first if it's changed
|
||||||
|
|
||||||
|
[
|
||||||
|
"st wattr 0x${device.deviceNetworkId} 1 0x201 0x23 0x30 {$next}", "delay 100" ,
|
||||||
|
|
||||||
|
"raw 0x201 {04 21 11 00 00 05 00 }","delay 200", // hold expiry time
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 1500",
|
||||||
|
] + getSetPointHoldDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
def compareWithNow(d)
|
||||||
|
{
|
||||||
|
long mins = (new Date(d)).getTime() - (new Date()).getTime()
|
||||||
|
|
||||||
|
mins /= 1000 * 60;
|
||||||
|
|
||||||
|
log.trace "mins: ${mins}"
|
||||||
|
|
||||||
|
boolean past = (mins < 0)
|
||||||
|
def ret = (past)? "" : "in "
|
||||||
|
|
||||||
|
if (past)
|
||||||
|
mins *= -1;
|
||||||
|
|
||||||
|
float t = 0;
|
||||||
|
// minutes
|
||||||
|
if (mins < 60)
|
||||||
|
{
|
||||||
|
ret += (mins as Integer) + " min" + ((mins > 1)? 's' : '')
|
||||||
|
}else if (mins < 1440)
|
||||||
|
{
|
||||||
|
t = ( Math.round((14 + mins)/30) as Integer) / 2
|
||||||
|
ret += t + " hr" + ((t > 1)? 's' : '')
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
t = (Math.round((359 + mins)/720) as Integer) / 2
|
||||||
|
ret += t + " day" + ((t > 1)? 's' : '')
|
||||||
|
}
|
||||||
|
ret += (past)? " ago": ""
|
||||||
|
|
||||||
|
log.trace "ret: ${ret}"
|
||||||
|
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
|
def convertToTime(data)
|
||||||
|
{
|
||||||
|
def time = Integer.parseInt("$data", 16) as long;
|
||||||
|
time *= 1000;
|
||||||
|
time += 946684800000; // 481418694
|
||||||
|
time -= location.timeZone.getRawOffset() + location.timeZone.getDSTSavings();
|
||||||
|
|
||||||
|
def d = new Date(time);
|
||||||
|
|
||||||
|
//log.trace "converted $data to Time $d"
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
def Program()
|
||||||
|
{
|
||||||
|
def currentSched = device.currentState("prorgammingOperation")?.value
|
||||||
|
|
||||||
|
def next = Integer.parseInt(currentSched?: "00", 16);
|
||||||
|
if ( (next & 0x01) == 0x01)
|
||||||
|
next = next & 0xfe;
|
||||||
|
else
|
||||||
|
next = next | 0x01;
|
||||||
|
|
||||||
|
def nextSched = getProgrammingMap()[next & 0x01]
|
||||||
|
|
||||||
|
"st wattr 0x${device.deviceNetworkId} 1 0x201 0x25 0x18 {$next}"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def getThermostatOperatingState(value)
|
||||||
|
{
|
||||||
|
String[] m = [ "heating", "cooling", "fan", "Heat2", "Cool2", "Fan2", "Fan3"]
|
||||||
|
String desc = 'idle'
|
||||||
|
value = Integer.parseInt(''+value, 16)
|
||||||
|
|
||||||
|
// only check for 1-stage for A1730
|
||||||
|
for ( i in 0..2 ) {
|
||||||
|
if (value & 1 << i)
|
||||||
|
desc = m[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
desc
|
||||||
|
}
|
||||||
|
|
||||||
|
def checkLastTimeSync(delay)
|
||||||
|
{
|
||||||
|
def lastSync = device.currentState("lastTimeSync")?.value
|
||||||
|
if (!lastSync)
|
||||||
|
lastSync = "${new Date(0)}"
|
||||||
|
|
||||||
|
if (settings.sync_clock ?: false && lastSync != new Date(0))
|
||||||
|
{
|
||||||
|
sendEvent("name":"lastTimeSync", "value":"${new Date(0)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
long duration = (new Date()).getTime() - (new Date(lastSync)).getTime()
|
||||||
|
|
||||||
|
// log.debug "check Time: $lastSync duration: ${duration} settings.sync_clock: ${settings.sync_clock}"
|
||||||
|
if (duration > 86400000)
|
||||||
|
{
|
||||||
|
sendEvent("name":"lastTimeSync", "value":"${new Date()}")
|
||||||
|
return setThermostatTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
def readAttributesCommand(cluster, attribList)
|
||||||
|
{
|
||||||
|
def attrString = ''
|
||||||
|
|
||||||
|
for ( val in attribList ) {
|
||||||
|
attrString += ' ' + String.format("%02X %02X", val & 0xff , (val >> 8) & 0xff)
|
||||||
|
}
|
||||||
|
|
||||||
|
//log.trace "list: " + attrString
|
||||||
|
|
||||||
|
["raw "+ cluster + " {00 00 00 $attrString}","delay 100",
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 100",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh()
|
||||||
|
{
|
||||||
|
log.debug "refresh called"
|
||||||
|
// log.trace "list: " + readAttributesCommand(0x201, [0x1C,0x1E,0x23])
|
||||||
|
|
||||||
|
readAttributesCommand(0x201, [0x00,0x11,0x12]) +
|
||||||
|
readAttributesCommand(0x201, [0x1C,0x1E,0x23]) +
|
||||||
|
readAttributesCommand(0x201, [0x24,0x25,0x29]) +
|
||||||
|
[
|
||||||
|
"st rattr 0x${device.deviceNetworkId} 1 0x204 0x01", "delay 200", // lock status
|
||||||
|
"raw 0x201 {04 21 11 00 00 05 00 }" , "delay 500", // hold expiary
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1" , "delay 1500",
|
||||||
|
] + checkLastTimeSync(2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def poll() {
|
||||||
|
log.trace "poll called"
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
def getTemperature(value) {
|
||||||
|
def celsius = Integer.parseInt("$value", 16) / 100
|
||||||
|
|
||||||
|
if(getTemperatureScale() == "C"){
|
||||||
|
return celsius as Integer
|
||||||
|
} else {
|
||||||
|
return celsiusToFahrenheit(celsius) as Integer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def setHeatingSetpoint(degrees) {
|
||||||
|
def temperatureScale = getTemperatureScale()
|
||||||
|
|
||||||
|
def degreesInteger = degrees as Integer
|
||||||
|
sendEvent("name":"heatingSetpoint", "value":degreesInteger)
|
||||||
|
|
||||||
|
def celsius = (getTemperatureScale() == "C") ? degreesInteger : (fahrenheitToCelsius(degreesInteger) as Double).round(2)
|
||||||
|
"st wattr 0x${device.deviceNetworkId} 1 0x201 0x12 0x29 {" + hex(celsius*100) + "}"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def setCoolingSetpoint(degrees) {
|
||||||
|
def degreesInteger = degrees as Integer
|
||||||
|
sendEvent("name":"coolingSetpoint", "value":degreesInteger)
|
||||||
|
def celsius = (getTemperatureScale() == "C") ? degreesInteger : (fahrenheitToCelsius(degreesInteger) as Double).round(2)
|
||||||
|
"st wattr 0x${device.deviceNetworkId} 1 0x201 0x11 0x29 {" + hex(celsius*100) + "}"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def modes() {
|
||||||
|
["off", "heat", "cool"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def setThermostatFanMode() {
|
||||||
|
def currentFanMode = device.currentState("thermostatFanMode")?.value
|
||||||
|
//log.debug "switching fan from current mode: $currentFanMode"
|
||||||
|
def returnCommand
|
||||||
|
|
||||||
|
switch (currentFanMode) {
|
||||||
|
case "fanAuto":
|
||||||
|
returnCommand = fanOn()
|
||||||
|
break
|
||||||
|
case "fanOn":
|
||||||
|
returnCommand = fanAuto()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if(!currentFanMode) { returnCommand = fanAuto() }
|
||||||
|
returnCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
def setThermostatMode() {
|
||||||
|
def currentMode = device.currentState("thermostatMode")?.value
|
||||||
|
def modeOrder = modes()
|
||||||
|
def index = modeOrder.indexOf(currentMode)
|
||||||
|
def next = index >= 0 && index < modeOrder.size() - 1 ? modeOrder[index + 1] : modeOrder[0]
|
||||||
|
|
||||||
|
setThermostatMode(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
def setThermostatMode(String next) {
|
||||||
|
def val = (getModeMap().find { it.value == next }?.key)?: "00"
|
||||||
|
|
||||||
|
// log.trace "mode changing to $next sending value: $val"
|
||||||
|
|
||||||
|
sendEvent("name":"thermostatMode", "value":"$next")
|
||||||
|
["st wattr 0x${device.deviceNetworkId} 1 0x201 0x1C 0x30 {$val}"] +
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
def setThermostatFanMode(String value) {
|
||||||
|
log.debug "setThermostatFanMode({$value})"
|
||||||
|
"$value"()
|
||||||
|
}
|
||||||
|
|
||||||
|
def off() {
|
||||||
|
setThermostatMode("off")
|
||||||
|
}
|
||||||
|
|
||||||
|
def cool() {
|
||||||
|
setThermostatMode("cool")}
|
||||||
|
|
||||||
|
def heat() {
|
||||||
|
setThermostatMode("heat")
|
||||||
|
}
|
||||||
|
|
||||||
|
def auto() {
|
||||||
|
setThermostatMode("auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
def on() {
|
||||||
|
fanOn()
|
||||||
|
}
|
||||||
|
|
||||||
|
def fanOn() {
|
||||||
|
sendEvent("name":"thermostatFanMode", "value":"fanOn")
|
||||||
|
"st wattr 0x${device.deviceNetworkId} 1 0x202 0 0x30 {04}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fanAuto() {
|
||||||
|
sendEvent("name":"thermostatFanMode", "value":"fanAuto")
|
||||||
|
"st wattr 0x${device.deviceNetworkId} 1 0x202 0 0x30 {05}"
|
||||||
|
}
|
||||||
|
|
||||||
|
def updated()
|
||||||
|
{
|
||||||
|
def lastSync = device.currentState("lastTimeSync")?.value
|
||||||
|
if ((settings.sync_clock ?: false) == false)
|
||||||
|
{
|
||||||
|
log.debug "resetting last sync time. Used to be: $lastSync"
|
||||||
|
sendEvent("name":"lastTimeSync", "value":"${new Date(0)}")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def getLockMap()
|
||||||
|
{[
|
||||||
|
"00":"Unlocked",
|
||||||
|
"01":"Mode Only",
|
||||||
|
"02":"Setpoint",
|
||||||
|
"03":"Full",
|
||||||
|
"04":"Full",
|
||||||
|
"05":"Full",
|
||||||
|
|
||||||
|
]}
|
||||||
|
def lock()
|
||||||
|
{
|
||||||
|
|
||||||
|
def currentLock = device.currentState("lockLevel")?.value
|
||||||
|
def val = getLockMap().find { it.value == currentLock }?.key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//log.debug "current lock is: ${val}"
|
||||||
|
|
||||||
|
if (val == "00")
|
||||||
|
val = getLockMap().find { it.value == (settings.lock_level ?: "Full") }?.key
|
||||||
|
else
|
||||||
|
val = "00"
|
||||||
|
|
||||||
|
"st rattr 0x${device.deviceNetworkId} 1 0x204 0x01"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def setThermostatTime()
|
||||||
|
{
|
||||||
|
|
||||||
|
if ((settings.sync_clock ?: false))
|
||||||
|
{
|
||||||
|
log.debug "sync time is disabled, leaving"
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Date date = new Date();
|
||||||
|
String zone = location.timeZone.getRawOffset() + " DST " + location.timeZone.getDSTSavings();
|
||||||
|
|
||||||
|
long millis = date.getTime(); // Millis since Unix epoch
|
||||||
|
millis -= 946684800000; // adjust for ZigBee EPOCH
|
||||||
|
// adjust for time zone and DST offset
|
||||||
|
millis += location.timeZone.getRawOffset() + location.timeZone.getDSTSavings();
|
||||||
|
//convert to seconds
|
||||||
|
millis /= 1000;
|
||||||
|
|
||||||
|
// print to a string for hex capture
|
||||||
|
String s = String.format("%08X", millis);
|
||||||
|
// hex capture for message format
|
||||||
|
String data = " " + s.substring(6, 8) + " " + s.substring(4, 6) + " " + s.substring(2, 4)+ " " + s.substring(0, 2);
|
||||||
|
|
||||||
|
[
|
||||||
|
"raw 0x201 {04 21 11 00 02 0f 00 23 ${data} }",
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure() {
|
||||||
|
|
||||||
|
[
|
||||||
|
"zdo bind 0x${device.deviceNetworkId} 1 1 0x201 {${device.zigbeeId}} {}", "delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x0000 0x29 20 300 {19 00}", // report temperature changes over 0.2C
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x001C 0x30 10 305 { }", // mode
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}","delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x0025 0x18 10 310 { 00 }", // schedule on/off
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}","delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x001E 0x30 10 315 { 00 }", // running mode
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}","delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x0011 0x29 10 320 {32 00}", // cooling setpoint delta: 0.5C (0x3200 in little endian)
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}","delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x0012 0x29 10 320 {32 00}", // cooling setpoint delta: 0.5C (0x3200 in little endian)
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}","delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x0029 0x19 10 325 { 00 }", "delay 200", // relay status
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}","delay 500",
|
||||||
|
|
||||||
|
"zcl global send-me-a-report 0x201 0x0023 0x30 10 330 { 00 }", // hold
|
||||||
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}","delay 1500",
|
||||||
|
|
||||||
|
] + refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private hex(value) {
|
||||||
|
new BigInteger(Math.round(value).toString()).toString(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEndpointId()
|
||||||
|
{
|
||||||
|
new BigInteger(device.endpointId, 16).toString()
|
||||||
|
}
|
||||||
@@ -15,19 +15,27 @@ metadata {
|
|||||||
// TODO: define status and reply messages here
|
// TODO: define status and reply messages here
|
||||||
}
|
}
|
||||||
|
|
||||||
tiles {
|
tiles(scale: 2) {
|
||||||
|
multiAttributeTile(name:"rich-control"){
|
||||||
|
tileAttribute ("device.switch", key: "PRIMARY_CONTROL") {
|
||||||
|
attributeState "default", label: "Hue Bridge", action: "", icon: "st.Lighting.light99-hue", backgroundColor: "#F3C200"
|
||||||
|
}
|
||||||
|
tileAttribute ("serialNumber", key: "SECONDARY_CONTROL") {
|
||||||
|
attributeState "default", label:'SN: ${currentValue}'
|
||||||
|
}
|
||||||
|
}
|
||||||
standardTile("icon", "icon", width: 1, height: 1, canChangeIcon: false, inactiveLabel: true, canChangeBackground: false) {
|
standardTile("icon", "icon", width: 1, height: 1, canChangeIcon: false, inactiveLabel: true, canChangeBackground: false) {
|
||||||
state "default", label: "Hue Bridge", action: "", icon: "st.Lighting.light99-hue", backgroundColor: "#FFFFFF"
|
state "default", label: "Hue Bridge", action: "", icon: "st.Lighting.light99-hue", backgroundColor: "#FFFFFF"
|
||||||
}
|
}
|
||||||
valueTile("serialNumber", "device.serialNumber", decoration: "flat", height: 1, width: 2, inactiveLabel: false) {
|
valueTile("serialNumber", "device.serialNumber", decoration: "flat", height: 1, width: 2, inactiveLabel: false) {
|
||||||
state "default", label:'SN: ${currentValue}'
|
state "default", label:'SN: ${currentValue}'
|
||||||
}
|
}
|
||||||
valueTile("networkAddress", "device.networkAddress", decoration: "flat", height: 1, width: 2, inactiveLabel: false) {
|
valueTile("networkAddress", "device.networkAddress", decoration: "flat", height: 2, width: 4, inactiveLabel: false) {
|
||||||
state "default", label:'${currentValue}', height: 1, width: 2, inactiveLabel: false
|
state "default", label:'${currentValue}', height: 1, width: 2, inactiveLabel: false
|
||||||
}
|
}
|
||||||
|
|
||||||
main (["icon"])
|
main (["icon"])
|
||||||
details(["networkAddress","serialNumber"])
|
details(["rich-control", "networkAddress"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +44,6 @@ def parse(description) {
|
|||||||
log.debug "Parsing '${description}'"
|
log.debug "Parsing '${description}'"
|
||||||
def results = []
|
def results = []
|
||||||
def result = parent.parse(this, description)
|
def result = parent.parse(this, description)
|
||||||
|
|
||||||
if (result instanceof physicalgraph.device.HubAction){
|
if (result instanceof physicalgraph.device.HubAction){
|
||||||
log.trace "HUE BRIDGE HubAction received -- DOES THIS EVER HAPPEN?"
|
log.trace "HUE BRIDGE HubAction received -- DOES THIS EVER HAPPEN?"
|
||||||
results << result
|
results << result
|
||||||
@@ -44,32 +51,30 @@ def parse(description) {
|
|||||||
//do nothing
|
//do nothing
|
||||||
log.trace "HUE BRIDGE was updated"
|
log.trace "HUE BRIDGE was updated"
|
||||||
} else {
|
} else {
|
||||||
log.trace "HUE BRIDGE, OTHER"
|
|
||||||
def map = description
|
def map = description
|
||||||
if (description instanceof String) {
|
if (description instanceof String) {
|
||||||
map = stringToMap(description)
|
map = stringToMap(description)
|
||||||
}
|
}
|
||||||
if (map?.name && map?.value) {
|
if (map?.name && map?.value) {
|
||||||
log.trace "HUE BRIDGE, GENERATING EVENT: $map.name: $map.value"
|
log.trace "HUE BRIDGE, GENERATING EVENT: $map.name: $map.value"
|
||||||
results << createEvent(name: "${map?.name}", value: "${map?.value}")
|
results << createEvent(name: "${map.name}", value: "${map.value}")
|
||||||
}
|
} else {
|
||||||
else {
|
log.trace "Parsing description"
|
||||||
log.trace "HUE BRIDGE, OTHER"
|
|
||||||
def msg = parseLanMessage(description)
|
def msg = parseLanMessage(description)
|
||||||
if (msg.body) {
|
if (msg.body) {
|
||||||
def contentType = msg.headers["Content-Type"]
|
def contentType = msg.headers["Content-Type"]
|
||||||
if (contentType?.contains("json")) {
|
if (contentType?.contains("json")) {
|
||||||
def bulbs = new groovy.json.JsonSlurper().parseText(msg.body)
|
def bulbs = new groovy.json.JsonSlurper().parseText(msg.body)
|
||||||
if (bulbs.state) {
|
if (bulbs.state) {
|
||||||
log.warn "NOT PROCESSED: $msg.body"
|
log.info "Bridge response: $msg.body"
|
||||||
}
|
} else {
|
||||||
else {
|
// Sending Bulbs List to parent"
|
||||||
log.debug "HUE BRIDGE, GENERATING BULB LIST EVENT: $bulbs"
|
if (parent.state.inBulbDiscovery)
|
||||||
sendEvent(name: "bulbList", value: device.hub.id, isStateChange: true, data: bulbs, displayed: false)
|
log.info parent.bulbListHandler(device.hub.id, msg.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (contentType?.contains("xml")) {
|
else if (contentType?.contains("xml")) {
|
||||||
log.debug "HUE BRIDGE, SWALLOWING BRIDGE DESCRIPTION RESPONSE -- BRIDGE ALREADY PRESENT"
|
log.debug "HUE BRIDGE ALREADY PRESENT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hue Bulb
|
* Hue Bulb
|
||||||
*
|
*
|
||||||
@@ -15,8 +16,8 @@ metadata {
|
|||||||
capability "Sensor"
|
capability "Sensor"
|
||||||
|
|
||||||
command "setAdjustedColor"
|
command "setAdjustedColor"
|
||||||
command "reset"
|
command "reset"
|
||||||
command "refresh"
|
command "refresh"
|
||||||
}
|
}
|
||||||
|
|
||||||
simulator {
|
simulator {
|
||||||
@@ -49,7 +50,6 @@ metadata {
|
|||||||
|
|
||||||
main(["switch"])
|
main(["switch"])
|
||||||
details(["switch", "levelSliderControl", "rgbSelector", "refresh", "reset"])
|
details(["switch", "levelSliderControl", "rgbSelector", "refresh", "reset"])
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse events into attributes
|
// parse events into attributes
|
||||||
@@ -68,13 +68,13 @@ def parse(description) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handle commands
|
// handle commands
|
||||||
def on(transition = "4") {
|
def on() {
|
||||||
log.trace parent.on(this,transition)
|
log.trace parent.on(this)
|
||||||
sendEvent(name: "switch", value: "on")
|
sendEvent(name: "switch", value: "on")
|
||||||
}
|
}
|
||||||
|
|
||||||
def off(transition = "4") {
|
def off() {
|
||||||
log.trace parent.off(this,transition)
|
log.trace parent.off(this)
|
||||||
sendEvent(name: "switch", value: "off")
|
sendEvent(name: "switch", value: "off")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +107,9 @@ def setHue(percent) {
|
|||||||
sendEvent(name: "hue", value: percent)
|
sendEvent(name: "hue", value: percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
def setColor(value,alert = "none",transition = 4) {
|
def setColor(value) {
|
||||||
log.debug "setColor: ${value}, $this"
|
log.debug "setColor: ${value}, $this"
|
||||||
parent.setColor(this, value, alert, transition)
|
parent.setColor(this, value)
|
||||||
if (value.hue) { sendEvent(name: "hue", value: value.hue)}
|
if (value.hue) { sendEvent(name: "hue", value: value.hue)}
|
||||||
if (value.saturation) { sendEvent(name: "saturation", value: value.saturation)}
|
if (value.saturation) { sendEvent(name: "saturation", value: value.saturation)}
|
||||||
if (value.hex) { sendEvent(name: "color", value: value.hex)}
|
if (value.hex) { sendEvent(name: "color", value: value.hex)}
|
||||||
|
|||||||
@@ -19,24 +19,41 @@ metadata {
|
|||||||
simulator {
|
simulator {
|
||||||
// TODO: define status and reply messages here
|
// TODO: define status and reply messages here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tiles(scale: 2) {
|
||||||
|
multiAttributeTile(name:"rich-control", type: "lighting", canChangeIcon: true){
|
||||||
|
tileAttribute ("device.switch", key: "PRIMARY_CONTROL") {
|
||||||
|
attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
attributeState "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
}
|
||||||
|
tileAttribute ("device.level", key: "SLIDER_CONTROL") {
|
||||||
|
attributeState "level", action:"switch level.setLevel", range:"(0..100)"
|
||||||
|
}
|
||||||
|
tileAttribute ("device.level", key: "SECONDARY_CONTROL") {
|
||||||
|
attributeState "level", label: 'Level ${currentValue}%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) {
|
||||||
|
state "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
state "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
state "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
state "turningOff", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") {
|
||||||
|
state "level", action:"switch level.setLevel"
|
||||||
|
}
|
||||||
|
|
||||||
|
standardTile("refresh", "device.switch", inactiveLabel: false, height: 2, width: 2, decoration: "flat") {
|
||||||
|
state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh"
|
||||||
|
}
|
||||||
|
|
||||||
standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) {
|
main(["switch"])
|
||||||
state "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821"
|
details(["rich-control", "refresh"])
|
||||||
state "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff"
|
}
|
||||||
}
|
|
||||||
standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") {
|
|
||||||
state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh"
|
|
||||||
}
|
|
||||||
controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") {
|
|
||||||
state "level", action:"switch level.setLevel"
|
|
||||||
}
|
|
||||||
valueTile("level", "device.level", inactiveLabel: false, decoration: "flat") {
|
|
||||||
state "level", label: 'Level ${currentValue}%'
|
|
||||||
}
|
|
||||||
|
|
||||||
main(["switch"])
|
|
||||||
details(["switch", "levelSliderControl", "refresh"])
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse events into attributes
|
// parse events into attributes
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* LIFX Color Bulb
|
||||||
|
*
|
||||||
|
* Copyright 2015 LIFX
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
metadata {
|
||||||
|
definition (name: "LIFX Color Bulb", namespace: "smartthings", author: "LIFX") {
|
||||||
|
capability "Actuator"
|
||||||
|
capability "Color Control"
|
||||||
|
capability "Color Temperature"
|
||||||
|
capability "Switch"
|
||||||
|
capability "Switch Level" // brightness
|
||||||
|
capability "Polling"
|
||||||
|
capability "Refresh"
|
||||||
|
capability "Sensor"
|
||||||
|
}
|
||||||
|
|
||||||
|
simulator {
|
||||||
|
// TODO: define status and reply messages here
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles {
|
||||||
|
standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) {
|
||||||
|
state "unreachable", label: "?", action:"refresh.refresh", icon:"st.switches.light.off", backgroundColor:"#666666"
|
||||||
|
state "on", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
state "off", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
state "turningOn", label:'Turning on', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
state "turningOff", label:'Turning off', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
}
|
||||||
|
standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh"
|
||||||
|
}
|
||||||
|
valueTile("null", "device.switch", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "default", label:''
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("rgbSelector", "device.color", "color", height: 3, width: 3, inactiveLabel: false) {
|
||||||
|
state "color", action:"setColor"
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false, range:"(0..100)") {
|
||||||
|
state "level", action:"switch level.setLevel"
|
||||||
|
}
|
||||||
|
valueTile("level", "device.level", inactiveLabel: false, icon: "st.illuminance.illuminance.light", decoration: "flat") {
|
||||||
|
state "level", label: '${currentValue}%'
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("colorTempSliderControl", "device.colorTemperature", "slider", height: 1, width: 2, inactiveLabel: false, range:"(2700..9000)") {
|
||||||
|
state "colorTemp", action:"color temperature.setColorTemperature"
|
||||||
|
}
|
||||||
|
valueTile("colorTemp", "device.colorTemperature", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "colorTemp", label: '${currentValue}K'
|
||||||
|
}
|
||||||
|
|
||||||
|
main(["switch"])
|
||||||
|
details(["switch", "refresh", "level", "levelSliderControl", "rgbSelector", "colorTempSliderControl", "colorTemp"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse events into attributes
|
||||||
|
def parse(String description) {
|
||||||
|
if (description == 'updated') {
|
||||||
|
return // don't poll when config settings is being updated as it may time out
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle commands
|
||||||
|
def setHue(percentage) {
|
||||||
|
log.debug "setHue ${percentage}"
|
||||||
|
parent.logErrors(logObject: log) {
|
||||||
|
def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "hue:${percentage * 3.6}"])
|
||||||
|
if (resp.status < 300) {
|
||||||
|
sendEvent(name: "hue", value: percentage)
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
} else {
|
||||||
|
log.error("Bad setHue result: [${resp.status}] ${resp.data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def setSaturation(percentage) {
|
||||||
|
log.debug "setSaturation ${percentage}"
|
||||||
|
parent.logErrors(logObject: log) {
|
||||||
|
def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "saturation:${percentage / 100}"])
|
||||||
|
if (resp.status < 300) {
|
||||||
|
sendEvent(name: "saturation", value: percentage)
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
} else {
|
||||||
|
log.error("Bad setSaturation result: [${resp.status}] ${resp.data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def setColor(Map color) {
|
||||||
|
log.debug "setColor ${color}"
|
||||||
|
def attrs = []
|
||||||
|
def events = []
|
||||||
|
color.each { key, value ->
|
||||||
|
switch (key) {
|
||||||
|
case "hue":
|
||||||
|
attrs << "hue:${value * 3.6}"
|
||||||
|
events << createEvent(name: "hue", value: value)
|
||||||
|
break
|
||||||
|
case "saturation":
|
||||||
|
attrs << "saturation:${value / 100}"
|
||||||
|
events << createEvent(name: "saturation", value: value)
|
||||||
|
break
|
||||||
|
case "colorTemperature":
|
||||||
|
attrs << "kelvin:${value}"
|
||||||
|
events << createEvent(name: "colorTemperature", value: value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent.logErrors(logObject:log) {
|
||||||
|
def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: attrs.join(" ")])
|
||||||
|
if (resp.status < 300) {
|
||||||
|
sendEvent(name: "color", value: color.hex)
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
events.each { sendEvent(it) }
|
||||||
|
} else {
|
||||||
|
log.error("Bad setColor result: [${resp.status}] ${resp.data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def setLevel(percentage) {
|
||||||
|
log.debug "setLevel ${percentage}"
|
||||||
|
if (percentage < 1 && percentage > 0) {
|
||||||
|
percentage = 1 // clamp to 1%
|
||||||
|
}
|
||||||
|
if (percentage == 0) {
|
||||||
|
sendEvent(name: "level", value: 0) // Otherwise the level value tile does not update
|
||||||
|
return off() // if the brightness is set to 0, just turn it off
|
||||||
|
}
|
||||||
|
parent.logErrors(logObject:log) {
|
||||||
|
def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", ["color": "brightness:${percentage / 100}"])
|
||||||
|
if (resp.status < 300) {
|
||||||
|
sendEvent(name: "level", value: percentage)
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
} else {
|
||||||
|
log.error("Bad setLevel result: [${resp.status}] ${resp.data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def setColorTemperature(kelvin) {
|
||||||
|
log.debug "Executing 'setColorTemperature' to ${kelvin}"
|
||||||
|
parent.logErrors() {
|
||||||
|
def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "kelvin:${kelvin}"])
|
||||||
|
if (resp.status < 300) {
|
||||||
|
sendEvent(name: "colorTemperature", value: kelvin)
|
||||||
|
sendEvent(name: "color", value: "#ffffff")
|
||||||
|
sendEvent(name: "saturation", value: 0)
|
||||||
|
} else {
|
||||||
|
log.error("Bad setLevel result: [${resp.status}] ${resp.data}")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def on() {
|
||||||
|
log.debug "Device setOn"
|
||||||
|
parent.logErrors() {
|
||||||
|
if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "on"]) != null) {
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def off() {
|
||||||
|
log.debug "Device setOff"
|
||||||
|
parent.logErrors() {
|
||||||
|
if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "off"]) != null) {
|
||||||
|
sendEvent(name: "switch", value: "off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def poll() {
|
||||||
|
log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}"
|
||||||
|
def resp = parent.apiGET("/lights/${device.deviceNetworkId}")
|
||||||
|
if (resp.status != 200) {
|
||||||
|
log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
def data = resp.data
|
||||||
|
|
||||||
|
sendEvent(name: "level", value: sprintf("%.1f", (data.brightness ?: 1) * 100))
|
||||||
|
sendEvent(name: "switch", value: data.connected ? data.power : "unreachable")
|
||||||
|
sendEvent(name: "color", value: colorUtil.hslToHex((data.color.hue / 3.6) as int, (data.color.saturation * 100) as int))
|
||||||
|
sendEvent(name: "hue", value: data.color.hue / 3.6)
|
||||||
|
sendEvent(name: "saturation", value: data.color.saturation * 100)
|
||||||
|
sendEvent(name: "colorTemperature", value: data.color.kelvin)
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh() {
|
||||||
|
log.debug "Executing 'refresh'"
|
||||||
|
poll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* LIFX White Bulb
|
||||||
|
*
|
||||||
|
* Copyright 2015 LIFX
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
metadata {
|
||||||
|
definition (name: "LIFX White Bulb", namespace: "smartthings", author: "LIFX") {
|
||||||
|
capability "Actuator"
|
||||||
|
capability "Color Temperature"
|
||||||
|
capability "Switch"
|
||||||
|
capability "Switch Level" // brightness
|
||||||
|
capability "Polling"
|
||||||
|
capability "Refresh"
|
||||||
|
capability "Sensor"
|
||||||
|
}
|
||||||
|
|
||||||
|
simulator {
|
||||||
|
// TODO: define status and reply messages here
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles {
|
||||||
|
standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) {
|
||||||
|
state "on", label:'${name}', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
state "off", label:'${name}', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
state "turningOn", label:'Turning on', action:"switch.off", icon:"st.switches.light.on", backgroundColor:"#79b821", nextState:"turningOff"
|
||||||
|
state "turningOff", label:'Turning off', action:"switch.on", icon:"st.switches.light.off", backgroundColor:"#ffffff", nextState:"turningOn"
|
||||||
|
state "unreachable", label: "?", action:"refresh.refresh", icon:"st.switches.light.off", backgroundColor:"#666666"
|
||||||
|
}
|
||||||
|
standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh"
|
||||||
|
}
|
||||||
|
valueTile("null", "device.switch", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "default", label:''
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false, range:"(0..100)") {
|
||||||
|
state "level", action:"switch level.setLevel"
|
||||||
|
}
|
||||||
|
valueTile("level", "device.level", inactiveLabel: false, icon: "st.illuminance.illuminance.light", decoration: "flat") {
|
||||||
|
state "level", label: '${currentValue}%'
|
||||||
|
}
|
||||||
|
|
||||||
|
controlTile("colorTempSliderControl", "device.colorTemperature", "slider", height: 1, width: 2, inactiveLabel: false, range:"(2700..6500)") {
|
||||||
|
state "colorTemp", action:"color temperature.setColorTemperature"
|
||||||
|
}
|
||||||
|
valueTile("colorTemp", "device.colorTemperature", inactiveLabel: false, decoration: "flat") {
|
||||||
|
state "colorTemp", label: '${currentValue}K'
|
||||||
|
}
|
||||||
|
|
||||||
|
main(["switch"])
|
||||||
|
details(["switch", "refresh", "level", "levelSliderControl", "colorTempSliderControl", "colorTemp"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse events into attributes
|
||||||
|
def parse(String description) {
|
||||||
|
if (description == 'updated') {
|
||||||
|
return // don't poll when config settings is being updated as it may time out
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle commands
|
||||||
|
def setLevel(percentage) {
|
||||||
|
log.debug "setLevel ${percentage}"
|
||||||
|
if (percentage < 1 && percentage > 0) {
|
||||||
|
percentage = 1 // clamp to 1%
|
||||||
|
}
|
||||||
|
if (percentage == 0) {
|
||||||
|
sendEvent(name: "level", value: 0) // Otherwise the level value tile does not update
|
||||||
|
return off() // if the brightness is set to 0, just turn it off
|
||||||
|
}
|
||||||
|
parent.logErrors(logObject:log) {
|
||||||
|
def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", ["color": "brightness:${percentage / 100}"])
|
||||||
|
if (resp.status < 300) {
|
||||||
|
sendEvent(name: "level", value: percentage)
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
} else {
|
||||||
|
log.error("Bad setLevel result: [${resp.status}] ${resp.data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def setColorTemperature(kelvin) {
|
||||||
|
log.debug "Executing 'setColorTemperature' to ${kelvin}"
|
||||||
|
parent.logErrors() {
|
||||||
|
def resp = parent.apiPUT("/lights/${device.deviceNetworkId}/color", [color: "kelvin:${kelvin}"])
|
||||||
|
if (resp.status < 300) {
|
||||||
|
sendEvent(name: "colorTemperature", value: kelvin)
|
||||||
|
sendEvent(name: "color", value: "#ffffff")
|
||||||
|
sendEvent(name: "saturation", value: 0)
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
} else {
|
||||||
|
log.error("Bad setColorTemperature result: [${resp.status}] ${resp.data}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def on() {
|
||||||
|
log.debug "Device setOn"
|
||||||
|
parent.logErrors() {
|
||||||
|
if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "on"]) != null) {
|
||||||
|
sendEvent(name: "switch", value: "on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def off() {
|
||||||
|
log.debug "Device setOff"
|
||||||
|
parent.logErrors() {
|
||||||
|
if (parent.apiPUT("/lights/${device.deviceNetworkId}/power", [state: "off"]) != null) {
|
||||||
|
sendEvent(name: "switch", value: "off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def poll() {
|
||||||
|
log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}"
|
||||||
|
def resp = parent.apiGET("/lights/${device.deviceNetworkId}")
|
||||||
|
if (resp.status != 200) {
|
||||||
|
log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
def data = resp.data
|
||||||
|
|
||||||
|
sendEvent(name: "level", value: sprintf("%f", (data.brightness ?: 1) * 100))
|
||||||
|
sendEvent(name: "switch", value: data.connected ? data.power : "unreachable")
|
||||||
|
sendEvent(name: "colorTemperature", value: data.color.kelvin)
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh() {
|
||||||
|
log.debug "Executing 'refresh'"
|
||||||
|
poll()
|
||||||
|
}
|
||||||
@@ -24,8 +24,8 @@ metadata {
|
|||||||
command "enrollResponse"
|
command "enrollResponse"
|
||||||
|
|
||||||
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3041"
|
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3041"
|
||||||
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3043"
|
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3043", deviceJoinName: "NYCE Ceiling Motion Sensor"
|
||||||
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3045"
|
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3045", deviceJoinName: "NYCE Curtain Motion Sensor"
|
||||||
}
|
}
|
||||||
|
|
||||||
tiles {
|
tiles {
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ metadata {
|
|||||||
command "enrollResponse"
|
command "enrollResponse"
|
||||||
|
|
||||||
|
|
||||||
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3011"
|
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3011", deviceJoinName: "NYCE Door/Window Sensor"
|
||||||
fingerprint inClusters: "0000,0001,0003,0500,0020", manufacturer: "NYCE", model: "3011"
|
fingerprint inClusters: "0000,0001,0003,0500,0020", manufacturer: "NYCE", model: "3011", deviceJoinName: "NYCE Door/Window Sensor"
|
||||||
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3014"
|
fingerprint inClusters: "0000,0001,0003,0406,0500,0020", manufacturer: "NYCE", model: "3014", deviceJoinName: "NYCE Tilt Sensor"
|
||||||
fingerprint inClusters: "0000,0001,0003,0500,0020", manufacturer: "NYCE", model: "3014"
|
fingerprint inClusters: "0000,0001,0003,0500,0020", manufacturer: "NYCE", model: "3014", deviceJoinName: "NYCE Tilt Sensor"
|
||||||
}
|
}
|
||||||
|
|
||||||
simulator {
|
simulator {
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ metadata {
|
|||||||
|
|
||||||
command "setAdjustedColor"
|
command "setAdjustedColor"
|
||||||
|
|
||||||
|
|
||||||
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0008,0300,0B04,FC0F", outClusters: "0019", manufacturer: "OSRAM", model: "Gardenspot RGB"
|
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0008,0300,0B04,FC0F", outClusters: "0019", manufacturer: "OSRAM", model: "Gardenspot RGB"
|
||||||
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0008,0300,0B04,FC0F", outClusters: "0019", manufacturer: "OSRAM", model: "LIGHTIFY Gardenspot RGB"
|
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0008,0300,0B04,FC0F", outClusters: "0019", manufacturer: "OSRAM", model: "LIGHTIFY Gardenspot RGB"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// simulator metadata
|
// simulator metadata
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ metadata {
|
|||||||
// indicates that device keeps track of heartbeat (in state.heartbeat)
|
// indicates that device keeps track of heartbeat (in state.heartbeat)
|
||||||
attribute "heartbeat", "string"
|
attribute "heartbeat", "string"
|
||||||
|
|
||||||
|
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0B04,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3200", deviceJoinName: "Outlet"
|
||||||
|
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0B04,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3200-Sgb", deviceJoinName: "Outlet"
|
||||||
|
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0B04,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "4257050-RZHAC", deviceJoinName: "Outlet"
|
||||||
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0B04,0B05", outClusters: "0019"
|
fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0B04,0B05", outClusters: "0019"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ metadata {
|
|||||||
command "enrollResponse"
|
command "enrollResponse"
|
||||||
|
|
||||||
|
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3315-S"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3315-S", deviceJoinName: "Water Leak Sensor"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3315"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3315"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3315-Seu"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3315-Seu", deviceJoinName: "Water Leak Sensor"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,16 +280,14 @@ def configure() {
|
|||||||
def configCmds = [
|
def configCmds = [
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
||||||
|
|
||||||
"zcl global send-me-a-report 1 0x20 0x20 300 0600 {01}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 1 {${device.zigbeeId}} {}", "delay 500",
|
||||||
|
"zcl global send-me-a-report 1 0x20 0x20 30 21600 {01}", //checkin time 6 hrs
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
||||||
|
|
||||||
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"zcl global send-me-a-report 0x402 0 0x29 30 3600 {6400}",
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500"
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 0x001 {${device.zigbeeId}} {}", "delay 500"
|
|
||||||
]
|
]
|
||||||
return configCmds + refresh() // send refresh cmds as part of config
|
return configCmds + refresh() // send refresh cmds as part of config
|
||||||
}
|
}
|
||||||
@@ -299,7 +297,7 @@ def enrollResponse() {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
[
|
[
|
||||||
//Resending the CIE in case the enroll request is sent before CIE is written
|
//Resending the CIE in case the enroll request is sent before CIE is written
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
//Enroll Response
|
//Enroll Response
|
||||||
"raw 0x500 {01 23 00 00 00}",
|
"raw 0x500 {01 23 00 00 00}",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ metadata {
|
|||||||
command "enrollResponse"
|
command "enrollResponse"
|
||||||
|
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3305-S"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3305-S"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3325-S"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3325-S", deviceJoinName: "Motion Sensor"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3305"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3305"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3325"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3325"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3326"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05", outClusters: "0019", manufacturer: "CentraLite", model: "3326"
|
||||||
@@ -301,20 +301,18 @@ def configure() {
|
|||||||
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
||||||
|
|
||||||
def configCmds = [
|
def configCmds = [
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0x20 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 1 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl global send-me-a-report 1 0x20 0x20 300 3600 {01}",
|
"zcl global send-me-a-report 1 0x20 0x20 30 21600 {01}", //checkin time 6 hrs
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0x402 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0x402 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}",
|
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500"
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 1 {${device.zigbeeId}} {}", "delay 200"
|
|
||||||
]
|
]
|
||||||
return configCmds + refresh() // send refresh cmds as part of config
|
return configCmds + refresh() // send refresh cmds as part of config
|
||||||
}
|
}
|
||||||
|
|
||||||
def enrollResponse() {
|
def enrollResponse() {
|
||||||
@@ -322,12 +320,12 @@ def enrollResponse() {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
[
|
[
|
||||||
//Resending the CIE in case the enroll request is sent before CIE is written
|
//Resending the CIE in case the enroll request is sent before CIE is written
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
//Enroll Response
|
//Enroll Response
|
||||||
"raw 0x500 {01 23 00 00 00}",
|
"raw 0x500 {01 23 00 00 00}",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 200"
|
"send 0x${device.deviceNetworkId} 1 1", "delay 200"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEndpointId() {
|
private getEndpointId() {
|
||||||
|
|||||||
@@ -292,18 +292,16 @@ def configure() {
|
|||||||
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
||||||
|
|
||||||
def configCmds = [
|
def configCmds = [
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0x20 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 1 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl global send-me-a-report 1 0x20 0x20 300 3600 {01}",
|
"zcl global send-me-a-report 1 0x20 0x20 30 21600 {01}", //checkin time 6 hrs
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0x402 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0x402 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}",
|
"zcl global send-me-a-report 0x402 0 0x29 30 3600 {6400}",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500"
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 1 {${device.zigbeeId}} {}", "delay 200"
|
|
||||||
]
|
]
|
||||||
return configCmds + refresh() // send refresh cmds as part of config
|
return configCmds + refresh() // send refresh cmds as part of config
|
||||||
}
|
}
|
||||||
@@ -313,7 +311,7 @@ def enrollResponse() {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
[
|
[
|
||||||
//Resending the CIE in case the enroll request is sent before CIE is written
|
//Resending the CIE in case the enroll request is sent before CIE is written
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
//Enroll Response
|
//Enroll Response
|
||||||
"raw 0x500 {01 23 00 00 00}",
|
"raw 0x500 {01 23 00 00 00}",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
command "enrollResponse"
|
command "enrollResponse"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05,FC02", outClusters: "0019", manufacturer: "CentraLite", model: "3320"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05,FC02", outClusters: "0019", manufacturer: "CentraLite", model: "3320"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05,FC02", outClusters: "0019", manufacturer: "CentraLite", model: "3321"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05,FC02", outClusters: "0019", manufacturer: "CentraLite", model: "3321"
|
||||||
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05,FC02", outClusters: "0019", manufacturer: "CentraLite", model: "3321-S"
|
fingerprint inClusters: "0000,0001,0003,0402,0500,0020,0B05,FC02", outClusters: "0019", manufacturer: "CentraLite", model: "3321-S", deviceJoinName: "Multipurpose Sensor"
|
||||||
|
|
||||||
attribute "status", "string"
|
attribute "status", "string"
|
||||||
}
|
}
|
||||||
@@ -395,35 +395,34 @@ def getTemperature(value) {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
log.debug "Configuring Reporting"
|
log.debug "Configuring Reporting"
|
||||||
|
|
||||||
def configCmds = [
|
def configCmds = [
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 1 {${device.zigbeeId}} {}", "delay 200",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0x20 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 1 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl global send-me-a-report 1 0x20 0x20 600 3600 {01}",
|
"zcl global send-me-a-report 1 0x20 0x20 30 21600 {01}", //checkin time 6 hrs
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0x402 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0x402 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}",
|
"zcl global send-me-a-report 0x402 0 0x29 30 3600 {6400}",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0xFC02 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0xFC02 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl mfg-code 0x104E",
|
"zcl mfg-code 0x104E",
|
||||||
"zcl global send-me-a-report 0xFC02 0x0010 0x18 300 3600 {01}",
|
"zcl global send-me-a-report 0xFC02 0x0010 0x18 10 3600 {01}",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zcl mfg-code 0x104E",
|
"zcl mfg-code 0x104E",
|
||||||
"zcl global send-me-a-report 0xFC02 0x0012 0x29 300 3600 {01}",
|
"zcl global send-me-a-report 0xFC02 0x0012 0x29 1 3600 {01}",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zcl mfg-code 0x104E",
|
"zcl mfg-code 0x104E",
|
||||||
"zcl global send-me-a-report 0xFC02 0x0013 0x29 300 3600 {01}",
|
"zcl global send-me-a-report 0xFC02 0x0013 0x29 1 3600 {01}",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zcl mfg-code 0x104E",
|
"zcl mfg-code 0x104E",
|
||||||
"zcl global send-me-a-report 0xFC02 0x0014 0x29 300 3600 {01}",
|
"zcl global send-me-a-report 0xFC02 0x0014 0x29 1 3600 {01}",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500"
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500"
|
||||||
|
|
||||||
]
|
]
|
||||||
@@ -440,7 +439,7 @@ def enrollResponse() {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
[
|
[
|
||||||
//Resending the CIE in case the enroll request is sent before CIE is written
|
//Resending the CIE in case the enroll request is sent before CIE is written
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
//Enroll Response
|
//Enroll Response
|
||||||
"raw 0x500 {01 23 00 00 00}",
|
"raw 0x500 {01 23 00 00 00}",
|
||||||
|
|||||||
@@ -297,29 +297,27 @@ def getTemperature(value) {
|
|||||||
return refreshCmds + enrollResponse()
|
return refreshCmds + enrollResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
def configure() {
|
def configure() {
|
||||||
|
|
||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
||||||
def configCmds = [
|
def configCmds = [
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 ${endpointId} 0x20 {${device.zigbeeId}} {}", "delay 200",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 1 {${device.zigbeeId}} {}", "delay 200",
|
||||||
"zcl global send-me-a-report 1 0x20 0x20 600 3600 {01}",
|
"zcl global send-me-a-report 1 0x20 0x20 30 21600 {01}", //checkin time 6 hrs
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
||||||
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}",
|
"zcl global send-me-a-report 0x402 0 0x29 30 3600 {6400}",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 0xFC02 {${device.zigbeeId}} {}", "delay 500",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0xFC02 {${device.zigbeeId}} {}", "delay 500",
|
||||||
"zcl global send-me-a-report 0xFC02 2 0x18 300 3600 {01}",
|
"zcl global send-me-a-report 0xFC02 2 0x18 30 3600 {01}",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500"
|
||||||
|
]
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}", "delay 500"
|
return configCmds + refresh() // send refresh cmds as part of config
|
||||||
]
|
|
||||||
return configCmds + refresh() // send refresh cmds as part of config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def enrollResponse() {
|
def enrollResponse() {
|
||||||
@@ -327,7 +325,7 @@ def enrollResponse() {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
[
|
[
|
||||||
//Resending the CIE in case the enroll request is sent before CIE is written
|
//Resending the CIE in case the enroll request is sent before CIE is written
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
//Enroll Response
|
//Enroll Response
|
||||||
"raw 0x500 {01 23 00 00 00}",
|
"raw 0x500 {01 23 00 00 00}",
|
||||||
|
|||||||
@@ -275,22 +275,16 @@ def configure() {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
log.debug "Configuring Reporting, IAS CIE, and Bindings."
|
||||||
def configCmds = [
|
def configCmds = [
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
||||||
|
|
||||||
"zcl global send-me-a-report 1 0x20 0x20 600 3600 {01}",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 1 {${device.zigbeeId}} {}", "delay 500",
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"zcl global send-me-a-report 1 0x20 0x20 30 21600 {01}", //checkin time 6 hrs
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
||||||
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}",
|
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
"zdo bind 0x${device.deviceNetworkId} ${endpointId} 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
||||||
|
"zcl global send-me-a-report 0x402 0 0x29 30 3600 {6400}",
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500"
|
||||||
//"raw 0x500 {01 23 00 00 00}", "delay 200",
|
|
||||||
//"send 0x${device.deviceNetworkId} 1 1", "delay 1500",
|
|
||||||
|
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}", "delay 500"
|
|
||||||
]
|
]
|
||||||
return configCmds + refresh() // send refresh cmds as part of config
|
return configCmds + refresh() // send refresh cmds as part of config
|
||||||
}
|
}
|
||||||
@@ -300,7 +294,7 @@ def enrollResponse() {
|
|||||||
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
String zigbeeEui = swapEndianHex(device.hub.zigbeeEui)
|
||||||
[
|
[
|
||||||
//Resending the CIE in case the enroll request is sent before CIE is written
|
//Resending the CIE in case the enroll request is sent before CIE is written
|
||||||
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}",
|
"zcl global write 0x500 0x10 0xf0 {${zigbeeEui}}", "delay 200",
|
||||||
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
"send 0x${device.deviceNetworkId} 1 ${endpointId}", "delay 500",
|
||||||
//Enroll Response
|
//Enroll Response
|
||||||
"raw 0x500 {01 23 00 00 00}",
|
"raw 0x500 {01 23 00 00 00}",
|
||||||
|
|||||||
@@ -253,22 +253,19 @@ def configure() {
|
|||||||
|
|
||||||
log.debug "Configuring Reporting and Bindings."
|
log.debug "Configuring Reporting and Bindings."
|
||||||
def configCmds = [
|
def configCmds = [
|
||||||
|
"zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}", "delay 500",
|
||||||
|
"zcl global send-me-a-report 1 0x20 0x20 30 21600 {01}", //checkin time 6 hrs
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
||||||
|
|
||||||
|
|
||||||
"zcl global send-me-a-report 1 0x20 0x20 600 3600 {0100}", "delay 500",
|
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 1000",
|
|
||||||
|
|
||||||
"zcl global send-me-a-report 0x402 0 0x29 300 3600 {6400}", "delay 200",
|
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 1500",
|
|
||||||
|
|
||||||
"zcl global send-me-a-report 0xFC45 0 0x29 300 3600 {6400}", "delay 200",
|
|
||||||
"send 0x${device.deviceNetworkId} 1 1", "delay 1500",
|
|
||||||
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 0xFC45 {${device.zigbeeId}} {}", "delay 1000",
|
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
"zdo bind 0x${device.deviceNetworkId} 1 1 0x402 {${device.zigbeeId}} {}", "delay 500",
|
||||||
"zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}"
|
"zcl global send-me-a-report 0x402 0 0x29 30 3600 {6400}",
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500",
|
||||||
|
|
||||||
|
"zdo bind 0x${device.deviceNetworkId} 1 1 0xFC45 {${device.zigbeeId}} {}", "delay 500",
|
||||||
|
"zcl global send-me-a-report 0xFC45 0 0x29 30 3600 {6400}",
|
||||||
|
"send 0x${device.deviceNetworkId} 1 1", "delay 500"
|
||||||
]
|
]
|
||||||
return configCmds + refresh() // send refresh cmds as part of config
|
return configCmds + refresh() // send refresh cmds as part of config
|
||||||
}
|
}
|
||||||
|
|
||||||
private hex(value) {
|
private hex(value) {
|
||||||
|
|||||||
@@ -11,8 +11,13 @@
|
|||||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
|
* 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
|
* 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.
|
* for the specific language governing permissions and limitations under the License.
|
||||||
*
|
*
|
||||||
|
* SmartThings data is sent from this SmartApp to Initial State. This is event data only for
|
||||||
|
* devices for which the user has authorized. Likewise, Initial State's services call this
|
||||||
|
* SmartApp on the user's behalf to configure Initial State specific parameters. The ToS and
|
||||||
|
* Privacy Policy for Initial State can be found here: https://www.initialstate.com/terms
|
||||||
*/
|
*/
|
||||||
|
|
||||||
definition(
|
definition(
|
||||||
name: "Initial State Event Streamer",
|
name: "Initial State Event Streamer",
|
||||||
namespace: "initialstate.events",
|
namespace: "initialstate.events",
|
||||||
@@ -28,32 +33,31 @@ import groovy.json.JsonSlurper
|
|||||||
|
|
||||||
preferences {
|
preferences {
|
||||||
section("Choose which devices to monitor...") {
|
section("Choose which devices to monitor...") {
|
||||||
//input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false
|
input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false
|
||||||
input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false
|
input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false
|
||||||
//input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false
|
input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false
|
||||||
//input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false
|
input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false
|
||||||
//input "buttons", "capability.button", title: "Buttons", multiple: true, required: false
|
input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false
|
||||||
//input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false
|
input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false
|
||||||
//input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false
|
|
||||||
input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false
|
input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false
|
||||||
//input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false
|
input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false
|
||||||
//input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false
|
input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false
|
||||||
//input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false
|
input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false
|
||||||
input "locks", "capability.lock", title: "Locks", multiple: true, required: false
|
input "locks", "capability.lock", title: "Locks", multiple: true, required: false
|
||||||
input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false
|
input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false
|
||||||
//input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false
|
input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false
|
||||||
//input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false
|
input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false
|
||||||
input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false
|
input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false
|
||||||
input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false
|
input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false
|
||||||
//input "relaySwitches", "capability.relaySwitch", title: "Relay Switches", multiple: true, required: false
|
input "relaySwitches", "capability.relaySwitch", title: "Relay Switches", multiple: true, required: false
|
||||||
//input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false
|
input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false
|
||||||
//input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false
|
input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false
|
||||||
//input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false
|
input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false
|
||||||
input "switches", "capability.switch", title: "Switches", multiple: true, required: false
|
input "switches", "capability.switch", title: "Switches", multiple: true, required: false
|
||||||
input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false
|
input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false
|
||||||
input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false
|
input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false
|
||||||
input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false
|
input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false
|
||||||
//input "valves", "capability.valve", title: "Valves", multiple: true, required: false
|
input "valves", "capability.valve", title: "Valves", multiple: true, required: false
|
||||||
input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false
|
input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,77 +78,71 @@ mappings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def subscribeToEvents() {
|
def subscribeToEvents() {
|
||||||
/*if (accelerometers != null) {
|
if (accelerometers != null) {
|
||||||
subscribe(accelerometers, "acceleration", genericHandler)
|
subscribe(accelerometers, "acceleration", genericHandler)
|
||||||
}*/
|
}
|
||||||
if (alarms != null) {
|
if (alarms != null) {
|
||||||
subscribe(alarms, "alarm", genericHandler)
|
subscribe(alarms, "alarm", genericHandler)
|
||||||
}
|
}
|
||||||
/*if (batteries != null) {
|
if (batteries != null) {
|
||||||
subscribe(batteries, "battery", genericHandler)
|
subscribe(batteries, "battery", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*if (beacons != null) {
|
if (beacons != null) {
|
||||||
subscribe(beacons, "presence", genericHandler)
|
subscribe(beacons, "presence", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*
|
|
||||||
if (buttons != null) {
|
if (cos != null) {
|
||||||
subscribe(buttons, "button", genericHandler)
|
|
||||||
}*/
|
|
||||||
/*if (cos != null) {
|
|
||||||
subscribe(cos, "carbonMonoxide", genericHandler)
|
subscribe(cos, "carbonMonoxide", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*if (colors != null) {
|
if (colors != null) {
|
||||||
subscribe(colors, "hue", genericHandler)
|
subscribe(colors, "hue", genericHandler)
|
||||||
subscribe(colors, "saturation", genericHandler)
|
subscribe(colors, "saturation", genericHandler)
|
||||||
subscribe(colors, "color", genericHandler)
|
subscribe(colors, "color", genericHandler)
|
||||||
}*/
|
}
|
||||||
if (contacts != null) {
|
if (contacts != null) {
|
||||||
subscribe(contacts, "contact", genericHandler)
|
subscribe(contacts, "contact", genericHandler)
|
||||||
}
|
}
|
||||||
/*if (doorsControllers != null) {
|
if (energyMeters != null) {
|
||||||
subscribe(doorsControllers, "door", genericHandler)
|
|
||||||
}*/
|
|
||||||
/*if (energyMeters != null) {
|
|
||||||
subscribe(energyMeters, "energy", genericHandler)
|
subscribe(energyMeters, "energy", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*if (illuminances != null) {
|
if (illuminances != null) {
|
||||||
subscribe(illuminances, "illuminance", genericHandler)
|
subscribe(illuminances, "illuminance", genericHandler)
|
||||||
}*/
|
}
|
||||||
if (locks != null) {
|
if (locks != null) {
|
||||||
subscribe(locks, "lock", genericHandler)
|
subscribe(locks, "lock", genericHandler)
|
||||||
}
|
}
|
||||||
if (motions != null) {
|
if (motions != null) {
|
||||||
subscribe(motions, "motion", genericHandler)
|
subscribe(motions, "motion", genericHandler)
|
||||||
}
|
}
|
||||||
/*if (musicPlayers != null) {
|
if (musicPlayers != null) {
|
||||||
subscribe(musicPlayers, "status", genericHandler)
|
subscribe(musicPlayers, "status", genericHandler)
|
||||||
subscribe(musicPlayers, "level", genericHandler)
|
subscribe(musicPlayers, "level", genericHandler)
|
||||||
subscribe(musicPlayers, "trackDescription", genericHandler)
|
subscribe(musicPlayers, "trackDescription", genericHandler)
|
||||||
subscribe(musicPlayers, "trackData", genericHandler)
|
subscribe(musicPlayers, "trackData", genericHandler)
|
||||||
subscribe(musicPlayers, "mute", genericHandler)
|
subscribe(musicPlayers, "mute", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*if (powerMeters != null) {
|
if (powerMeters != null) {
|
||||||
subscribe(powerMeters, "power", genericHandler)
|
subscribe(powerMeters, "power", genericHandler)
|
||||||
}*/
|
}
|
||||||
if (presences != null) {
|
if (presences != null) {
|
||||||
subscribe(presences, "presence", genericHandler)
|
subscribe(presences, "presence", genericHandler)
|
||||||
}
|
}
|
||||||
if (humidities != null) {
|
if (humidities != null) {
|
||||||
subscribe(humidities, "humidity", genericHandler)
|
subscribe(humidities, "humidity", genericHandler)
|
||||||
}
|
}
|
||||||
/*if (relaySwitches != null) {
|
if (relaySwitches != null) {
|
||||||
subscribe(relaySwitches, "switch", genericHandler)
|
subscribe(relaySwitches, "switch", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*if (sleepSensors != null) {
|
if (sleepSensors != null) {
|
||||||
subscribe(sleepSensors, "sleeping", genericHandler)
|
subscribe(sleepSensors, "sleeping", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*if (smokeDetectors != null) {
|
if (smokeDetectors != null) {
|
||||||
subscribe(smokeDetectors, "smoke", genericHandler)
|
subscribe(smokeDetectors, "smoke", genericHandler)
|
||||||
}*/
|
}
|
||||||
/*if (peds != null) {
|
if (peds != null) {
|
||||||
subscribe(peds, "steps", genericHandler)
|
subscribe(peds, "steps", genericHandler)
|
||||||
subscribe(peds, "goal", genericHandler)
|
subscribe(peds, "goal", genericHandler)
|
||||||
}*/
|
}
|
||||||
if (switches != null) {
|
if (switches != null) {
|
||||||
subscribe(switches, "switch", genericHandler)
|
subscribe(switches, "switch", genericHandler)
|
||||||
}
|
}
|
||||||
@@ -163,9 +161,9 @@ def subscribeToEvents() {
|
|||||||
subscribe(thermostats, "thermostatFanMode", genericHandler)
|
subscribe(thermostats, "thermostatFanMode", genericHandler)
|
||||||
subscribe(thermostats, "thermostatOperatingState", genericHandler)
|
subscribe(thermostats, "thermostatOperatingState", genericHandler)
|
||||||
}
|
}
|
||||||
/*if (valves != null) {
|
if (valves != null) {
|
||||||
subscribe(valves, "contact", genericHandler)
|
subscribe(valves, "contact", genericHandler)
|
||||||
}*/
|
}
|
||||||
if (waterSensors != null) {
|
if (waterSensors != null) {
|
||||||
subscribe(waterSensors, "water", genericHandler)
|
subscribe(waterSensors, "water", genericHandler)
|
||||||
}
|
}
|
||||||
@@ -173,23 +171,23 @@ def subscribeToEvents() {
|
|||||||
|
|
||||||
def getAccessKey() {
|
def getAccessKey() {
|
||||||
log.trace "get access key"
|
log.trace "get access key"
|
||||||
if (state.accessKey == null) {
|
if (atomicState.accessKey == null) {
|
||||||
httpError(404, "Access Key Not Found")
|
httpError(404, "Access Key Not Found")
|
||||||
} else {
|
} else {
|
||||||
[
|
[
|
||||||
accessKey: state.accessKey
|
accessKey: atomicState.accessKey
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def getBucketKey() {
|
def getBucketKey() {
|
||||||
log.trace "get bucket key"
|
log.trace "get bucket key"
|
||||||
if (state.bucketKey == null) {
|
if (atomicState.bucketKey == null) {
|
||||||
httpError(404, "Bucket key Not Found")
|
httpError(404, "Bucket key Not Found")
|
||||||
} else {
|
} else {
|
||||||
[
|
[
|
||||||
bucketKey: state.bucketKey,
|
bucketKey: atomicState.bucketKey,
|
||||||
bucketName: state.bucketName
|
bucketName: atomicState.bucketName
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,53 +200,94 @@ def setBucketKey() {
|
|||||||
log.debug "bucket name: $newBucketName"
|
log.debug "bucket name: $newBucketName"
|
||||||
log.debug "bucket key: $newBucketKey"
|
log.debug "bucket key: $newBucketKey"
|
||||||
|
|
||||||
if (newBucketKey && (newBucketKey != state.bucketKey || newBucketName != state.bucketName)) {
|
if (newBucketKey && (newBucketKey != atomicState.bucketKey || newBucketName != atomicState.bucketName)) {
|
||||||
state.bucketKey = "$newBucketKey"
|
atomicState.bucketKey = "$newBucketKey"
|
||||||
state.bucketName = "$newBucketName"
|
atomicState.bucketName = "$newBucketName"
|
||||||
state.isBucketCreated = false
|
atomicState.isBucketCreated = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tryCreateBucket()
|
||||||
}
|
}
|
||||||
|
|
||||||
def setAccessKey() {
|
def setAccessKey() {
|
||||||
log.trace "set access key"
|
log.trace "set access key"
|
||||||
def newAccessKey = request.JSON?.accessKey
|
def newAccessKey = request.JSON?.accessKey
|
||||||
|
def newGrokerSubdomain = request.JSON?.grokerSubdomain
|
||||||
|
|
||||||
if (newAccessKey && newAccessKey != state.accessKey) {
|
if (newGrokerSubdomain && newGrokerSubdomain != "" && newGrokerSubdomain != atomicState.grokerSubdomain) {
|
||||||
state.accessKey = "$newAccessKey"
|
atomicState.grokerSubdomain = "$newGrokerSubdomain"
|
||||||
state.isBucketCreated = false
|
atomicState.isBucketCreated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAccessKey && newAccessKey != atomicState.accessKey) {
|
||||||
|
atomicState.accessKey = "$newAccessKey"
|
||||||
|
atomicState.isBucketCreated = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def installed() {
|
def installed() {
|
||||||
|
atomicState.version = "1.0.18"
|
||||||
subscribeToEvents()
|
subscribeToEvents()
|
||||||
|
|
||||||
state.isBucketCreated = false
|
atomicState.isBucketCreated = false
|
||||||
|
atomicState.grokerSubdomain = "groker"
|
||||||
|
atomicState.eventBuffer = []
|
||||||
|
|
||||||
|
runEvery15Minutes(flushBuffer)
|
||||||
|
|
||||||
|
log.debug "installed (version $atomicState.version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
def updated() {
|
def updated() {
|
||||||
|
atomicState.version = "1.0.18"
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
|
|
||||||
if (state.bucketKey != null && state.accessKey != null) {
|
if (atomicState.bucketKey != null && atomicState.accessKey != null) {
|
||||||
state.isBucketCreated = false
|
atomicState.isBucketCreated = false
|
||||||
}
|
}
|
||||||
|
if (atomicState.eventBuffer == null) {
|
||||||
|
atomicState.eventBuffer = []
|
||||||
|
}
|
||||||
|
if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") {
|
||||||
|
atomicState.grokerSubdomain = "groker"
|
||||||
|
}
|
||||||
|
|
||||||
subscribeToEvents()
|
subscribeToEvents()
|
||||||
|
|
||||||
|
log.debug "updated (version $atomicState.version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
def createBucket() {
|
def uninstalled() {
|
||||||
|
log.debug "uninstalled (version $atomicState.version)"
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.bucketName) {
|
def tryCreateBucket() {
|
||||||
state.bucketName = state.bucketKey
|
|
||||||
|
// can't ship events if there is no grokerSubdomain
|
||||||
|
if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") {
|
||||||
|
log.error "streaming url is currently null"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the bucket has already been created, no need to continue
|
||||||
|
if (atomicState.isBucketCreated) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!atomicState.bucketName) {
|
||||||
|
atomicState.bucketName = atomicState.bucketKey
|
||||||
}
|
}
|
||||||
def bucketName = "${state.bucketName}"
|
if (!atomicState.accessKey) {
|
||||||
def bucketKey = "${state.bucketKey}"
|
return
|
||||||
def accessKey = "${state.accessKey}"
|
}
|
||||||
|
def bucketName = "${atomicState.bucketName}"
|
||||||
|
def bucketKey = "${atomicState.bucketKey}"
|
||||||
|
def accessKey = "${atomicState.accessKey}"
|
||||||
|
|
||||||
def bucketCreateBody = new JsonSlurper().parseText("{\"bucketKey\": \"$bucketKey\", \"bucketName\": \"$bucketName\"}")
|
def bucketCreateBody = new JsonSlurper().parseText("{\"bucketKey\": \"$bucketKey\", \"bucketName\": \"$bucketName\"}")
|
||||||
|
|
||||||
def bucketCreatePost = [
|
def bucketCreatePost = [
|
||||||
uri: 'https://groker.initialstate.com/api/buckets',
|
uri: "https://${atomicState.grokerSubdomain}.initialstate.com/api/buckets",
|
||||||
headers: [
|
headers: [
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-IS-AccessKey": accessKey
|
"X-IS-AccessKey": accessKey
|
||||||
@@ -258,10 +297,20 @@ def createBucket() {
|
|||||||
|
|
||||||
log.debug bucketCreatePost
|
log.debug bucketCreatePost
|
||||||
|
|
||||||
httpPostJson(bucketCreatePost) {
|
try {
|
||||||
log.debug "bucket posted"
|
// Create a bucket on Initial State so the data has a logical grouping
|
||||||
state.isBucketCreated = true
|
httpPostJson(bucketCreatePost) { resp ->
|
||||||
|
log.debug "bucket posted"
|
||||||
|
if (resp.status >= 400) {
|
||||||
|
log.error "bucket not created successfully"
|
||||||
|
} else {
|
||||||
|
atomicState.isBucketCreated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error "bucket creation error: $e"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def genericHandler(evt) {
|
def genericHandler(evt) {
|
||||||
@@ -273,33 +322,73 @@ def genericHandler(evt) {
|
|||||||
}
|
}
|
||||||
def value = "$evt.value"
|
def value = "$evt.value"
|
||||||
|
|
||||||
|
tryCreateBucket()
|
||||||
|
|
||||||
eventHandler(key, value)
|
eventHandler(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
def eventHandler(name, value) {
|
// This is a handler function for flushing the event buffer
|
||||||
|
// after a specified amount of time to reduce the load on ST servers
|
||||||
|
def flushBuffer() {
|
||||||
|
log.trace "About to flush the buffer on schedule"
|
||||||
|
if (atomicState.eventBuffer != null && atomicState.eventBuffer.size() > 0) {
|
||||||
|
tryShipEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.accessKey == null || state.bucketKey == null) {
|
def eventHandler(name, value) {
|
||||||
|
log.debug atomicState.eventBuffer
|
||||||
|
|
||||||
|
def eventBuffer = atomicState.eventBuffer
|
||||||
|
def epoch = now() / 1000
|
||||||
|
eventBuffer << [key: "$name", value: "$value", epoch: "$epoch"]
|
||||||
|
|
||||||
|
log.debug eventBuffer
|
||||||
|
|
||||||
|
atomicState.eventBuffer = eventBuffer
|
||||||
|
|
||||||
|
if (eventBuffer.size() >= 10) {
|
||||||
|
tryShipEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a helper function for shipping the atomicState.eventBuffer to Initial State
|
||||||
|
def tryShipEvents() {
|
||||||
|
|
||||||
|
// can't ship events if there is no grokerSubdomain
|
||||||
|
if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") {
|
||||||
|
log.error "streaming url is currently null"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// can't ship if access key and bucket key are null, so finish trying
|
||||||
|
if (atomicState.accessKey == null || atomicState.bucketKey == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isBucketCreated) {
|
|
||||||
createBucket()
|
|
||||||
}
|
|
||||||
|
|
||||||
def eventBody = new JsonSlurper().parseText("[{\"key\": \"$name\", \"value\": \"$value\"}]")
|
|
||||||
def eventPost = [
|
def eventPost = [
|
||||||
uri: 'https://groker.initialstate.com/api/events',
|
uri: "https://${atomicState.grokerSubdomain}.initialstate.com/api/events",
|
||||||
headers: [
|
headers: [
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-IS-BucketKey": "${state.bucketKey}",
|
"X-IS-BucketKey": "${atomicState.bucketKey}",
|
||||||
"X-IS-AccessKey": "${state.accessKey}"
|
"X-IS-AccessKey": "${atomicState.accessKey}",
|
||||||
|
"Accept-Version": "0.0.2"
|
||||||
],
|
],
|
||||||
body: eventBody
|
body: atomicState.eventBuffer
|
||||||
]
|
]
|
||||||
|
|
||||||
log.debug eventPost
|
try {
|
||||||
|
// post the events to initial state
|
||||||
httpPostJson(eventPost) {
|
httpPostJson(eventPost) { resp ->
|
||||||
log.debug "event data posted"
|
log.debug "shipped events and got ${resp.status}"
|
||||||
|
if (resp.status >= 400) {
|
||||||
|
log.error "shipping failed... ${resp.data}"
|
||||||
|
} else {
|
||||||
|
// clear the buffer
|
||||||
|
atomicState.eventBuffer = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error "shipping events failed: $e"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
194
smartapps/skp19/hello-home-cube.src/hello-home-cube.groovy
Normal file
194
smartapps/skp19/hello-home-cube.src/hello-home-cube.groovy
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Hello Home Cube
|
||||||
|
*
|
||||||
|
* Copyright 2015 skp19
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/************
|
||||||
|
* Metadata *
|
||||||
|
************/
|
||||||
|
definition(
|
||||||
|
name: "Hello Home Cube",
|
||||||
|
namespace: "skp19",
|
||||||
|
author: "skp19",
|
||||||
|
description: "Run a Hello Home action by rotating a cube containing a SmartSense Multi",
|
||||||
|
category: "SmartThings Labs",
|
||||||
|
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld.png",
|
||||||
|
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld@2x.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
import groovy.json.JsonSlurper
|
||||||
|
|
||||||
|
/**********
|
||||||
|
* Setup *
|
||||||
|
**********/
|
||||||
|
preferences {
|
||||||
|
page(name: "mainPage", title: "", nextPage: "scenesPage", uninstall: true) {
|
||||||
|
section("Use the orientation of this cube") {
|
||||||
|
input "cube", "capability.threeAxis", required: false, title: "SmartSense Multi sensor"
|
||||||
|
}
|
||||||
|
section([title: " ", mobileOnly:true]) {
|
||||||
|
label title: "Assign a name", required: false
|
||||||
|
mode title: "Set for specific mode(s)", required: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page(name: "scenesPage", title: "Scenes", install: true, uninstall: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scenesPage() {
|
||||||
|
log.debug "scenesPage()"
|
||||||
|
def sceneId = getOrientation()
|
||||||
|
dynamicPage(name:"scenesPage") {
|
||||||
|
def phrases = location.helloHome?.getPhrases()*.label
|
||||||
|
section {
|
||||||
|
phrases.sort()
|
||||||
|
input name: "homeAction1", type: "enum", title: "${1}. ${sceneName(1)}${sceneId==1 ? ' (current)' : ''}", required: false, options: phrases
|
||||||
|
input name: "homeAction2", type: "enum", title: "${2}. ${sceneName(2)}${sceneId==2 ? ' (current)' : ''}", required: false, options: phrases
|
||||||
|
input name: "homeAction3", type: "enum", title: "${3}. ${sceneName(3)}${sceneId==3 ? ' (current)' : ''}", required: false, options: phrases
|
||||||
|
input name: "homeAction4", type: "enum", title: "${4}. ${sceneName(4)}${sceneId==4 ? ' (current)' : ''}", required: false, options: phrases
|
||||||
|
input name: "homeAction5", type: "enum", title: "${5}. ${sceneName(5)}${sceneId==5 ? ' (current)' : ''}", required: false, options: phrases
|
||||||
|
input name: "homeAction6", type: "enum", title: "${6}. ${sceneName(6)}${sceneId==6 ? ' (current)' : ''}", required: false, options: phrases
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
href "scenesPage", title: "Refresh", description: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*************************
|
||||||
|
* Installation & update *
|
||||||
|
*************************/
|
||||||
|
def installed() {
|
||||||
|
log.debug "Installed with settings: ${settings}"
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
def updated() {
|
||||||
|
log.debug "Updated with settings: ${settings}"
|
||||||
|
|
||||||
|
unsubscribe()
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
def initialize() {
|
||||||
|
subscribe cube, "threeAxis", positionHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/******************
|
||||||
|
* Event handlers *
|
||||||
|
******************/
|
||||||
|
def positionHandler(evt) {
|
||||||
|
|
||||||
|
def sceneId = getOrientation(evt.xyzValue)
|
||||||
|
log.trace "orientation: $sceneId"
|
||||||
|
|
||||||
|
if (sceneId != state.lastActiveSceneId) {
|
||||||
|
runHomeAction(sceneId)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.trace "No status change"
|
||||||
|
}
|
||||||
|
state.lastActiveSceneId = sceneId
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/******************
|
||||||
|
* Helper methods *
|
||||||
|
******************/
|
||||||
|
private Boolean sceneIsDefined(sceneId) {
|
||||||
|
def tgt = "onoff_${sceneId}".toString()
|
||||||
|
settings.find{it.key.startsWith(tgt)} != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSetting(name, value) {
|
||||||
|
app.updateSetting(name, value)
|
||||||
|
settings[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
private runHomeAction(sceneId) {
|
||||||
|
log.trace "runHomeAction($sceneId)"
|
||||||
|
|
||||||
|
//RUN HELLO HOME ACTION
|
||||||
|
def homeAction
|
||||||
|
if (sceneId == 1) {
|
||||||
|
homeAction = homeAction1
|
||||||
|
}
|
||||||
|
if (sceneId == 2) {
|
||||||
|
homeAction = homeAction2
|
||||||
|
}
|
||||||
|
if (sceneId == 3) {
|
||||||
|
homeAction = homeAction3
|
||||||
|
}
|
||||||
|
if (sceneId == 4) {
|
||||||
|
homeAction = homeAction4
|
||||||
|
}
|
||||||
|
if (sceneId == 5) {
|
||||||
|
homeAction = homeAction5
|
||||||
|
}
|
||||||
|
if (sceneId == 6) {
|
||||||
|
homeAction = homeAction6
|
||||||
|
}
|
||||||
|
|
||||||
|
if (homeAction) {
|
||||||
|
location.helloHome.execute(homeAction)
|
||||||
|
log.trace "Running Home Action: $homeAction"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.trace "No Home Action Defined for Current State"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrientation(xyz=null) {
|
||||||
|
final threshold = 250
|
||||||
|
|
||||||
|
def value = xyz ?: cube.currentValue("threeAxis")
|
||||||
|
|
||||||
|
def x = Math.abs(value.x) > threshold ? (value.x > 0 ? 1 : -1) : 0
|
||||||
|
def y = Math.abs(value.y) > threshold ? (value.y > 0 ? 1 : -1) : 0
|
||||||
|
def z = Math.abs(value.z) > threshold ? (value.z > 0 ? 1 : -1) : 0
|
||||||
|
|
||||||
|
def orientation = 0
|
||||||
|
if (z > 0) {
|
||||||
|
if (x == 0 && y == 0) {
|
||||||
|
orientation = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (z < 0) {
|
||||||
|
if (x == 0 && y == 0) {
|
||||||
|
orientation = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (x > 0) {
|
||||||
|
if (y == 0) {
|
||||||
|
orientation = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (x < 0) {
|
||||||
|
if (y == 0) {
|
||||||
|
orientation = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (y > 0) {
|
||||||
|
orientation = 5
|
||||||
|
}
|
||||||
|
else if (y < 0) {
|
||||||
|
orientation = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orientation
|
||||||
|
}
|
||||||
|
|
||||||
|
private sceneName(num) {
|
||||||
|
final names = ["UNDEFINED","One","Two","Three","Four","Five","Six"]
|
||||||
|
settings."sceneName${num}" ?: "Scene ${names[num]}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
/**
|
||||||
|
* Bose SoundTouch (Connect)
|
||||||
|
*
|
||||||
|
* Copyright 2015 SmartThings
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
definition(
|
||||||
|
name: "Bose SoundTouch (Connect)",
|
||||||
|
namespace: "smartthings",
|
||||||
|
author: "SmartThings",
|
||||||
|
description: "Control your Bose SoundTouch speakers",
|
||||||
|
category: "SmartThings Labs",
|
||||||
|
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 {
|
||||||
|
page(name:"deviceDiscovery", title:"Device Setup", content:"deviceDiscovery", refreshTimeout:5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the urn that we're looking for
|
||||||
|
*
|
||||||
|
* @return URN which we are looking for
|
||||||
|
*
|
||||||
|
* @todo This + getUSNQualifier should be one and should use regular expressions
|
||||||
|
*/
|
||||||
|
def getDeviceType() {
|
||||||
|
return "urn:schemas-upnp-org:device:MediaRenderer:1" // Bose
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If not null, returns an additional qualifier for ssdUSN
|
||||||
|
* to avoid spamming the network
|
||||||
|
*
|
||||||
|
* @return Additional qualifier OR null if not needed
|
||||||
|
*/
|
||||||
|
def getUSNQualifier() {
|
||||||
|
return "uuid:BO5EBO5E-F00D-F00D-FEED-"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the name of the new device to instantiate in the user's smartapps
|
||||||
|
* This must be an app owned by the namespace (see #getNameSpace).
|
||||||
|
*
|
||||||
|
* @return name
|
||||||
|
*/
|
||||||
|
def getDeviceName() {
|
||||||
|
return "Bose SoundTouch"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the namespace this app and siblings use
|
||||||
|
*
|
||||||
|
* @return namespace
|
||||||
|
*/
|
||||||
|
def getNameSpace() {
|
||||||
|
return "smartthings"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deviceDiscovery page used by preferences. Will automatically
|
||||||
|
* make calls to the underlying discovery mechanisms as well as update
|
||||||
|
* whenever new devices are discovered AND verified.
|
||||||
|
*
|
||||||
|
* @return a dynamicPage() object
|
||||||
|
*/
|
||||||
|
def deviceDiscovery()
|
||||||
|
{
|
||||||
|
if(canInstallLabs())
|
||||||
|
{
|
||||||
|
def refreshInterval = 3 // Number of seconds between refresh
|
||||||
|
int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int
|
||||||
|
state.deviceRefreshCount = deviceRefreshCount + refreshInterval
|
||||||
|
|
||||||
|
def devices = getSelectableDevice()
|
||||||
|
def numFound = devices.size() ?: 0
|
||||||
|
|
||||||
|
// Make sure we get location updates (contains LAN data such as SSDP results, etc)
|
||||||
|
subscribeNetworkEvents()
|
||||||
|
|
||||||
|
//device discovery request every 15s
|
||||||
|
if((deviceRefreshCount % 15) == 0) {
|
||||||
|
discoverDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify request every 3 seconds except on discoveries
|
||||||
|
if(((deviceRefreshCount % 3) == 0) && ((deviceRefreshCount % 15) != 0)) {
|
||||||
|
verifyDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.trace "Discovered devices: ${devices}"
|
||||||
|
|
||||||
|
return dynamicPage(name:"deviceDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) {
|
||||||
|
section("Please wait while we discover your ${getDeviceName()}. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") {
|
||||||
|
input "selecteddevice", "enum", required:false, title:"Select ${getDeviceName()} (${numFound} found)", multiple:true, options:devices
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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:"deviceDiscovery", title:"Upgrade needed!", nextPage:"", install:true, uninstall: true) {
|
||||||
|
section("Upgrade") {
|
||||||
|
paragraph "$upgradeNeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by SmartThings Cloud when user has selected device(s) and
|
||||||
|
* pressed "Install".
|
||||||
|
*/
|
||||||
|
def installed() {
|
||||||
|
log.trace "Installed with settings: ${settings}"
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by SmartThings Cloud when app has been updated
|
||||||
|
*/
|
||||||
|
def updated() {
|
||||||
|
log.trace "Updated with settings: ${settings}"
|
||||||
|
unsubscribe()
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by SmartThings Cloud when user uninstalls the app
|
||||||
|
*
|
||||||
|
* We don't need to manually do anything here because any children
|
||||||
|
* are automatically removed upon the removal of the parent.
|
||||||
|
*
|
||||||
|
* Only time to do anything here is when you need to notify
|
||||||
|
* the remote end. And even then you're discouraged from removing
|
||||||
|
* the children manually.
|
||||||
|
*/
|
||||||
|
def uninstalled() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If user has selected devices, will start monitoring devices
|
||||||
|
* for changes (new address, port, etc...)
|
||||||
|
*/
|
||||||
|
def initialize() {
|
||||||
|
log.trace "initialize()"
|
||||||
|
state.subscribe = false
|
||||||
|
if (selecteddevice) {
|
||||||
|
addDevice()
|
||||||
|
refreshDevices()
|
||||||
|
subscribeNetworkEvents(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the child devices based on the user's selection
|
||||||
|
*
|
||||||
|
* Uses selecteddevice defined in the deviceDiscovery() page
|
||||||
|
*/
|
||||||
|
def addDevice(){
|
||||||
|
def devices = getVerifiedDevices()
|
||||||
|
def devlist
|
||||||
|
log.trace "Adding childs"
|
||||||
|
|
||||||
|
// If only one device is selected, we don't get a list (when using simulator)
|
||||||
|
if (!(selecteddevice instanceof List)) {
|
||||||
|
devlist = [selecteddevice]
|
||||||
|
} else {
|
||||||
|
devlist = selecteddevice
|
||||||
|
}
|
||||||
|
|
||||||
|
log.trace "These are being installed: ${devlist}"
|
||||||
|
|
||||||
|
devlist.each { dni ->
|
||||||
|
def d = getChildDevice(dni)
|
||||||
|
if(!d) {
|
||||||
|
def newDevice = devices.find { (it.value.mac) == dni }
|
||||||
|
def deviceName = newDevice?.value.name
|
||||||
|
if (!deviceName)
|
||||||
|
deviceName = getDeviceName() + "[${newDevice?.value.name}]"
|
||||||
|
d = addChildDevice(getNameSpace(), getDeviceName(), dni, newDevice?.value.hub, [label:"${deviceName}"])
|
||||||
|
d.boseSetDeviceID(newDevice.value.deviceID)
|
||||||
|
log.trace "Created ${d.displayName} with id $dni"
|
||||||
|
} else {
|
||||||
|
log.trace "${d.displayName} with id $dni already exists"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a DeviceNetworkId to an address. Primarily used by children
|
||||||
|
*
|
||||||
|
* @param dni Device Network id
|
||||||
|
* @return address or null
|
||||||
|
*/
|
||||||
|
def resolveDNI2Address(dni) {
|
||||||
|
def device = getVerifiedDevices().find { (it.value.mac) == dni }
|
||||||
|
if (device) {
|
||||||
|
return convertHexToIP(device.value.networkAddress)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins a child to the "Play Everywhere" zone
|
||||||
|
*
|
||||||
|
* @param child The speaker joining the zone
|
||||||
|
* @return A list of maps with POST data
|
||||||
|
*/
|
||||||
|
def boseZoneJoin(child) {
|
||||||
|
log = child.log // So we can debug this function
|
||||||
|
|
||||||
|
def results = []
|
||||||
|
def result = [:]
|
||||||
|
|
||||||
|
// Find the master (if any)
|
||||||
|
def server = getChildDevices().find{ it.boseGetZone() == "server" }
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
log.debug "boseJoinZone() We have a server already, so lets add the new speaker"
|
||||||
|
child.boseSetZone("client")
|
||||||
|
|
||||||
|
result['endpoint'] = "/setZone"
|
||||||
|
result['host'] = server.getDeviceIP() + ":8090"
|
||||||
|
result['body'] = "<zone master=\"${server.boseGetDeviceID()}\" senderIPAddress=\"${server.getDeviceIP()}\">"
|
||||||
|
getChildDevices().each{ it ->
|
||||||
|
log.trace "child: " + child
|
||||||
|
log.trace "zone : " + it.boseGetZone()
|
||||||
|
if (it.boseGetZone() || it.boseGetDeviceID() == child.boseGetDeviceID())
|
||||||
|
result['body'] = result['body'] + "<member ipaddress=\"${it.getDeviceIP()}\">${it.boseGetDeviceID()}</member>"
|
||||||
|
}
|
||||||
|
result['body'] = result['body'] + '</zone>'
|
||||||
|
} else {
|
||||||
|
log.debug "boseJoinZone() No server, add it!"
|
||||||
|
result['endpoint'] = "/setZone"
|
||||||
|
result['host'] = child.getDeviceIP() + ":8090"
|
||||||
|
result['body'] = "<zone master=\"${child.boseGetDeviceID()}\" senderIPAddress=\"${child.getDeviceIP()}\">"
|
||||||
|
result['body'] = result['body'] + "<member ipaddress=\"${child.getDeviceIP()}\">${child.boseGetDeviceID()}</member>"
|
||||||
|
result['body'] = result['body'] + '</zone>'
|
||||||
|
child.boseSetZone("server")
|
||||||
|
}
|
||||||
|
results << result
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
def boseZoneReset() {
|
||||||
|
getChildDevices().each{ it.boseSetZone(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
def boseZoneHasMaster() {
|
||||||
|
return getChildDevices().find{ it.boseGetZone() == "server" } != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a speaker from the play everywhere zone.
|
||||||
|
*
|
||||||
|
* @param child Which speaker is leaving
|
||||||
|
* @return a list of maps with POST data
|
||||||
|
*/
|
||||||
|
def boseZoneLeave(child) {
|
||||||
|
log = child.log // So we can debug this function
|
||||||
|
|
||||||
|
def results = []
|
||||||
|
def result = [:]
|
||||||
|
|
||||||
|
// First, tag us as a non-member
|
||||||
|
child.boseSetZone(null)
|
||||||
|
|
||||||
|
// Find the master (if any)
|
||||||
|
def server = getChildDevices().find{ it.boseGetZone() == "server" }
|
||||||
|
|
||||||
|
if (server && server.boseGetDeviceID() != child.boseGetDeviceID()) {
|
||||||
|
log.debug "boseLeaveZone() We have a server, so tell him we're leaving"
|
||||||
|
result['endpoint'] = "/removeZoneSlave"
|
||||||
|
result['host'] = server.getDeviceIP() + ":8090"
|
||||||
|
result['body'] = "<zone master=\"${server.boseGetDeviceID()}\" senderIPAddress=\"${server.getDeviceIP()}\">"
|
||||||
|
result['body'] = result['body'] + "<member ipaddress=\"${child.getDeviceIP()}\">${child.boseGetDeviceID()}</member>"
|
||||||
|
result['body'] = result['body'] + '</zone>'
|
||||||
|
results << result
|
||||||
|
} else {
|
||||||
|
log.debug "boseLeaveZone() No server, then...uhm, we probably were it!"
|
||||||
|
// Dismantle the entire thing, first send this to master
|
||||||
|
result['endpoint'] = "/removeZoneSlave"
|
||||||
|
result['host'] = child.getDeviceIP() + ":8090"
|
||||||
|
result['body'] = "<zone master=\"${child.boseGetDeviceID()}\" senderIPAddress=\"${child.getDeviceIP()}\">"
|
||||||
|
getChildDevices().each{ dev ->
|
||||||
|
if (dev.boseGetZone() || dev.boseGetDeviceID() == child.boseGetDeviceID())
|
||||||
|
result['body'] = result['body'] + "<member ipaddress=\"${dev.getDeviceIP()}\">${dev.boseGetDeviceID()}</member>"
|
||||||
|
}
|
||||||
|
result['body'] = result['body'] + '</zone>'
|
||||||
|
results << result
|
||||||
|
|
||||||
|
// Also issue this to each individual client
|
||||||
|
getChildDevices().each{ dev ->
|
||||||
|
if (dev.boseGetZone() && dev.boseGetDeviceID() != child.boseGetDeviceID()) {
|
||||||
|
log.trace "Additional device: " + dev
|
||||||
|
result['host'] = dev.getDeviceIP() + ":8090"
|
||||||
|
results << result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define our XML parsers
|
||||||
|
*
|
||||||
|
* @return mapping of root-node <-> parser function
|
||||||
|
*/
|
||||||
|
def getParsers() {
|
||||||
|
[
|
||||||
|
"root" : "parseDESC",
|
||||||
|
"info" : "parseINFO"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when location has changed, contains information from
|
||||||
|
* network transactions. See deviceDiscovery() for where it is
|
||||||
|
* registered.
|
||||||
|
*
|
||||||
|
* @param evt Holds event information
|
||||||
|
*/
|
||||||
|
def onLocation(evt) {
|
||||||
|
// Convert the event into something we can use
|
||||||
|
def lanEvent = parseLanMessage(evt.description, true)
|
||||||
|
lanEvent << ["hub":evt?.hubId]
|
||||||
|
|
||||||
|
// Determine what we need to do...
|
||||||
|
if (lanEvent?.ssdpTerm?.contains(getDeviceType()) &&
|
||||||
|
(getUSNQualifier() == null ||
|
||||||
|
lanEvent?.ssdpUSN?.contains(getUSNQualifier())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
parseSSDP(lanEvent)
|
||||||
|
}
|
||||||
|
else if (
|
||||||
|
lanEvent.headers && lanEvent.body &&
|
||||||
|
lanEvent.headers."content-type".contains("xml")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
def parsers = getParsers()
|
||||||
|
def xmlData = new XmlSlurper().parseText(lanEvent.body)
|
||||||
|
|
||||||
|
// Let each parser take a stab at it
|
||||||
|
parsers.each { node,func ->
|
||||||
|
if (xmlData.name() == node)
|
||||||
|
"$func"(xmlData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles SSDP description file.
|
||||||
|
*
|
||||||
|
* @param xmlData
|
||||||
|
*/
|
||||||
|
private def parseDESC(xmlData) {
|
||||||
|
log.info "parseDESC()"
|
||||||
|
|
||||||
|
def devicetype = getDeviceType().toLowerCase()
|
||||||
|
def devicetxml = body.device.deviceType.text().toLowerCase()
|
||||||
|
|
||||||
|
// Make sure it's the type we want
|
||||||
|
if (devicetxml == devicetype) {
|
||||||
|
def devices = getDevices()
|
||||||
|
def device = devices.find {it?.key?.contains(xmlData?.device?.UDN?.text())}
|
||||||
|
if (device && !device.value?.verified) {
|
||||||
|
// Unlike regular DESC, we cannot trust this just yet, parseINFO() decides all
|
||||||
|
device.value << [name:xmlData?.device?.friendlyName?.text(),model:xmlData?.device?.modelName?.text(), serialNumber:xmlData?.device?.serialNum?.text()]
|
||||||
|
} else {
|
||||||
|
log.error "parseDESC(): The xml file returned a device that didn't exist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle BOSE <info></info> result. This is an alternative to
|
||||||
|
* using the SSDP description standard. Some of the speakers do
|
||||||
|
* not support SSDP description, so we need this as well.
|
||||||
|
*
|
||||||
|
* @param xmlData
|
||||||
|
*/
|
||||||
|
private def parseINFO(xmlData) {
|
||||||
|
log.info "parseINFO()"
|
||||||
|
def devicetype = getDeviceType().toLowerCase()
|
||||||
|
|
||||||
|
def deviceID = xmlData.attributes()['deviceID']
|
||||||
|
def device = getDevices().find {it?.key?.contains(deviceID)}
|
||||||
|
if (device && !device.value?.verified) {
|
||||||
|
device.value << [name:xmlData?.name?.text(),model:xmlData?.type?.text(), serialNumber:xmlData?.serialNumber?.text(), "deviceID":deviceID, verified: true]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles SSDP discovery messages and adds them to the list
|
||||||
|
* of discovered devices. If it already exists, it will update
|
||||||
|
* the port and location (in case it was moved).
|
||||||
|
*
|
||||||
|
* @param lanEvent
|
||||||
|
*/
|
||||||
|
def parseSSDP(lanEvent) {
|
||||||
|
//SSDP DISCOVERY EVENTS
|
||||||
|
def USN = lanEvent.ssdpUSN.toString()
|
||||||
|
def devices = getDevices()
|
||||||
|
|
||||||
|
if (!(devices."${USN}")) {
|
||||||
|
//device does not exist
|
||||||
|
log.trace "parseSDDP() Adding Device \"${USN}\" to known list"
|
||||||
|
devices << ["${USN}":lanEvent]
|
||||||
|
} else {
|
||||||
|
// update the values
|
||||||
|
def d = devices."${USN}"
|
||||||
|
if (d.networkAddress != lanEvent.networkAddress || d.deviceAddress != lanEvent.deviceAddress) {
|
||||||
|
log.trace "parseSSDP() Updating device location (ip & port)"
|
||||||
|
d.networkAddress = lanEvent.networkAddress
|
||||||
|
d.deviceAddress = lanEvent.deviceAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a Map object which can be used with a preference page
|
||||||
|
* to represent a list of devices detected and verified.
|
||||||
|
*
|
||||||
|
* @return Map with zero or more devices
|
||||||
|
*/
|
||||||
|
Map getSelectableDevice() {
|
||||||
|
def devices = getVerifiedDevices()
|
||||||
|
def map = [:]
|
||||||
|
devices.each {
|
||||||
|
def value = "${it.value.name}"
|
||||||
|
def key = it.value.mac
|
||||||
|
map["${key}"] = value
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the refresh loop, making sure to keep us up-to-date with changes
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
private refreshDevices() {
|
||||||
|
discoverDevices()
|
||||||
|
verifyDevices()
|
||||||
|
runIn(300, "refreshDevices")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a subscription for network events
|
||||||
|
*
|
||||||
|
* @param force If true, will unsubscribe and subscribe if necessary (Optional, default false)
|
||||||
|
*/
|
||||||
|
private subscribeNetworkEvents(force=false) {
|
||||||
|
if (force) {
|
||||||
|
unsubscribe()
|
||||||
|
state.subscribe = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!state.subscribe) {
|
||||||
|
subscribe(location, null, onLocation, [filterEvents:false])
|
||||||
|
state.subscribe = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a SSDP M-SEARCH over the LAN for a specific type (see getDeviceType())
|
||||||
|
*/
|
||||||
|
private discoverDevices() {
|
||||||
|
log.trace "discoverDevice() Issuing SSDP request"
|
||||||
|
sendHubCommand(new physicalgraph.device.HubAction("lan discovery ${getDeviceType()}", physicalgraph.device.Protocol.LAN))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks through the list of unverified devices and issues a verification
|
||||||
|
* request for each of them (basically calling verifyDevice() per unverified)
|
||||||
|
*/
|
||||||
|
private verifyDevices() {
|
||||||
|
def devices = getDevices().findAll { it?.value?.verified != true }
|
||||||
|
|
||||||
|
devices.each {
|
||||||
|
verifyDevice(
|
||||||
|
it?.value?.mac,
|
||||||
|
convertHexToIP(it?.value?.networkAddress),
|
||||||
|
convertHexToInt(it?.value?.deviceAddress),
|
||||||
|
it?.value?.ssdpPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the device, in this case, we need to obtain the info block which
|
||||||
|
* holds information such as the actual mac to use in certain scenarios.
|
||||||
|
*
|
||||||
|
* Without this mac (henceforth referred to as deviceID), we can't do multi-speaker
|
||||||
|
* functions.
|
||||||
|
*
|
||||||
|
* @param deviceNetworkId The DNI of the device
|
||||||
|
* @param ip The address of the device on the network (not the same as DNI)
|
||||||
|
* @param port The port to use (0 will be treated as invalid and will use 80)
|
||||||
|
* @param devicessdpPath The URL path (for example, /desc)
|
||||||
|
*
|
||||||
|
* @note Result is captured in locationHandler()
|
||||||
|
*/
|
||||||
|
private verifyDevice(String deviceNetworkId, String ip, int port, String devicessdpPath) {
|
||||||
|
if(ip) {
|
||||||
|
def address = ip + ":8090"
|
||||||
|
sendHubCommand(new physicalgraph.device.HubAction([
|
||||||
|
method: "GET",
|
||||||
|
path: "/info",
|
||||||
|
headers: [
|
||||||
|
HOST: address,
|
||||||
|
]]))
|
||||||
|
} else {
|
||||||
|
log.warn("verifyDevice() IP address was empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of devices which have been verified
|
||||||
|
*
|
||||||
|
* @return array of verified devices
|
||||||
|
*/
|
||||||
|
def getVerifiedDevices() {
|
||||||
|
getDevices().findAll{ it?.value?.verified == true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all discovered devices or an empty array if none
|
||||||
|
*
|
||||||
|
* @return array of devices
|
||||||
|
*/
|
||||||
|
def getDevices() {
|
||||||
|
state.devices = state.devices ?: [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a hexadecimal string to an integer
|
||||||
|
*
|
||||||
|
* @param hex The string with a hexadecimal value
|
||||||
|
* @return An integer
|
||||||
|
*/
|
||||||
|
private Integer convertHexToInt(hex) {
|
||||||
|
Integer.parseInt(hex,16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an IP address represented as 0xAABBCCDD to AAA.BBB.CCC.DDD
|
||||||
|
*
|
||||||
|
* @param hex Address represented in hex
|
||||||
|
* @return String containing normal IPv4 dot notation
|
||||||
|
*/
|
||||||
|
private String convertHexToIP(hex) {
|
||||||
|
if (hex)
|
||||||
|
[convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".")
|
||||||
|
else
|
||||||
|
hex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if this setup can support SmarthThing Labs items
|
||||||
|
*
|
||||||
|
* @return true if it supports it.
|
||||||
|
*/
|
||||||
|
private Boolean canInstallLabs()
|
||||||
|
{
|
||||||
|
return hasAllHubsOver("000.011.00603")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if the firmwares on all hubs owned by user match or exceed the
|
||||||
|
* provided version number.
|
||||||
|
*
|
||||||
|
* @param desiredFirmware The version that must match or exceed
|
||||||
|
* @return true if hub has same or newer
|
||||||
|
*/
|
||||||
|
private Boolean hasAllHubsOver(String desiredFirmware)
|
||||||
|
{
|
||||||
|
return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a list of firmware version for every hub the user has
|
||||||
|
*
|
||||||
|
* @return List of firmwares
|
||||||
|
*/
|
||||||
|
private List getRealHubFirmwareVersions()
|
||||||
|
{
|
||||||
|
return location.hubs*.firmwareVersionString.findAll { it }
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
* for the specific language governing permissions and limitations under the License.
|
* for the specific language governing permissions and limitations under the License.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
definition(
|
definition(
|
||||||
name: "Hue (Connect)",
|
name: "Hue (Connect)",
|
||||||
namespace: "smartthings",
|
namespace: "smartthings",
|
||||||
@@ -64,10 +64,13 @@ def bridgeDiscovery(params=[:])
|
|||||||
def options = bridges ?: []
|
def options = bridges ?: []
|
||||||
def numFound = options.size() ?: 0
|
def numFound = options.size() ?: 0
|
||||||
|
|
||||||
if(!state.subscribe) {
|
if (numFound == 0 && state.bridgeRefreshCount > 25) {
|
||||||
subscribe(location, null, locationHandler, [filterEvents:false])
|
log.trace "Cleaning old bridges memory"
|
||||||
state.subscribe = true
|
state.bridges = [:]
|
||||||
}
|
state.bridgeRefreshCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(location, null, locationHandler, [filterEvents:false])
|
||||||
|
|
||||||
//bridge discovery request every 15 //25 seconds
|
//bridge discovery request every 15 //25 seconds
|
||||||
if((bridgeRefreshCount % 5) == 0) {
|
if((bridgeRefreshCount % 5) == 0) {
|
||||||
@@ -94,11 +97,20 @@ def bridgeLinking()
|
|||||||
|
|
||||||
def nextPage = ""
|
def nextPage = ""
|
||||||
def title = "Linking with your Hue"
|
def title = "Linking with your Hue"
|
||||||
def paragraphText = "Press the button on your Hue Bridge to setup a link."
|
def paragraphText
|
||||||
|
def hueimage = null
|
||||||
|
if (selectedHue) {
|
||||||
|
paragraphText = "Press the button on your Hue Bridge to setup a link. "
|
||||||
|
hueimage = "http://huedisco.mediavibe.nl/wp-content/uploads/2013/09/pair-bridge.png"
|
||||||
|
} else {
|
||||||
|
paragraphText = "You haven't selected a Hue Bridge, please Press \"Done\" and select one before clicking next."
|
||||||
|
hueimage = null
|
||||||
|
}
|
||||||
if (state.username) { //if discovery worked
|
if (state.username) { //if discovery worked
|
||||||
nextPage = "bulbDiscovery"
|
nextPage = "bulbDiscovery"
|
||||||
title = "Success! - click 'Next'"
|
title = "Success!"
|
||||||
paragraphText = "Linking to your hub was a success! Please click 'Next'!"
|
paragraphText = "Linking to your hub was a success! Please click 'Next'!"
|
||||||
|
hueimage = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if((linkRefreshcount % 2) == 0 && !state.username) {
|
if((linkRefreshcount % 2) == 0 && !state.username) {
|
||||||
@@ -106,18 +118,20 @@ def bridgeLinking()
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dynamicPage(name:"bridgeBtnPush", title:title, nextPage:nextPage, refreshInterval:refreshInterval) {
|
return dynamicPage(name:"bridgeBtnPush", title:title, nextPage:nextPage, refreshInterval:refreshInterval) {
|
||||||
section("Button Press") {
|
section("") {
|
||||||
paragraph """${paragraphText}"""
|
paragraph """${paragraphText}"""
|
||||||
|
if (hueimage != null)
|
||||||
|
image "${hueimage}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def bulbDiscovery()
|
def bulbDiscovery() {
|
||||||
{
|
|
||||||
int bulbRefreshCount = !state.bulbRefreshCount ? 0 : state.bulbRefreshCount as int
|
int bulbRefreshCount = !state.bulbRefreshCount ? 0 : state.bulbRefreshCount as int
|
||||||
state.bulbRefreshCount = bulbRefreshCount + 1
|
state.bulbRefreshCount = bulbRefreshCount + 1
|
||||||
def refreshInterval = 3
|
def refreshInterval = 3
|
||||||
|
state.inBulbDiscovery = true
|
||||||
|
state.bridgeRefreshCount = 0
|
||||||
def options = bulbsDiscovered() ?: []
|
def options = bulbsDiscovered() ?: []
|
||||||
def numFound = options.size() ?: 0
|
def numFound = options.size() ?: 0
|
||||||
|
|
||||||
@@ -129,7 +143,7 @@ def bulbDiscovery()
|
|||||||
section("Please wait while we discover your Hue Bulbs. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") {
|
section("Please wait while we discover your Hue Bulbs. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") {
|
||||||
input "selectedBulbs", "enum", required:false, title:"Select Hue Bulbs (${numFound} found)", multiple:true, options:options
|
input "selectedBulbs", "enum", required:false, title:"Select Hue Bulbs (${numFound} found)", multiple:true, options:options
|
||||||
}
|
}
|
||||||
section {
|
section {
|
||||||
def title = getBridgeIP() ? "Hue bridge (${getBridgeIP()})" : "Find bridges"
|
def title = getBridgeIP() ? "Hue bridge (${getBridgeIP()})" : "Find bridges"
|
||||||
href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true]
|
href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true]
|
||||||
|
|
||||||
@@ -194,21 +208,22 @@ Map bridgesDiscovered() {
|
|||||||
|
|
||||||
Map bulbsDiscovered() {
|
Map bulbsDiscovered() {
|
||||||
def bulbs = getHueBulbs()
|
def bulbs = getHueBulbs()
|
||||||
def map = [:]
|
def bulbmap = [:]
|
||||||
if (bulbs instanceof java.util.Map) {
|
if (bulbs instanceof java.util.Map) {
|
||||||
bulbs.each {
|
bulbs.each {
|
||||||
def value = "${it?.value?.name}"
|
def value = "${it.value.name}"
|
||||||
def key = app.id +"/"+ it?.value?.id
|
def key = app.id +"/"+ it.value.id
|
||||||
map["${key}"] = value
|
bulbmap["${key}"] = value
|
||||||
}
|
}
|
||||||
} else { //backwards compatable
|
} else { //backwards compatable
|
||||||
bulbs.each {
|
bulbs.each {
|
||||||
def value = "${it?.name}"
|
def value = "${it.name}"
|
||||||
def key = app.id +"/"+ it?.id
|
def key = app.id +"/"+ it.id
|
||||||
map["${key}"] = value
|
logg += "$value - $key, "
|
||||||
|
bulbmap["${key}"] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
map
|
bulbmap
|
||||||
}
|
}
|
||||||
|
|
||||||
def getHueBulbs() {
|
def getHueBulbs() {
|
||||||
@@ -231,24 +246,16 @@ def installed() {
|
|||||||
def updated() {
|
def updated() {
|
||||||
log.trace "Updated with settings: ${settings}"
|
log.trace "Updated with settings: ${settings}"
|
||||||
unschedule()
|
unschedule()
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
initialize()
|
initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
def initialize() {
|
def initialize() {
|
||||||
log.debug "Initializing"
|
log.debug "Initializing"
|
||||||
state.subscribe = false
|
state.inBulbDiscovery = false
|
||||||
state.bridgeSelectedOverride = false
|
|
||||||
def bridge = null
|
|
||||||
|
|
||||||
if (selectedHue) {
|
if (selectedHue) {
|
||||||
addBridge()
|
addBridge()
|
||||||
bridge = getChildDevice(selectedHue)
|
addBulbs()
|
||||||
subscribe(bridge, "bulbList", bulbListHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedBulbs) {
|
|
||||||
addBulbs()
|
|
||||||
doDeviceSync()
|
doDeviceSync()
|
||||||
runEvery5Minutes("doDeviceSync")
|
runEvery5Minutes("doDeviceSync")
|
||||||
}
|
}
|
||||||
@@ -263,22 +270,27 @@ def manualRefresh() {
|
|||||||
|
|
||||||
def uninstalled(){
|
def uninstalled(){
|
||||||
state.bridges = [:]
|
state.bridges = [:]
|
||||||
state.subscribe = false
|
state.username = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles events to add new bulbs
|
// Handles events to add new bulbs
|
||||||
def bulbListHandler(evt) {
|
def bulbListHandler(hub, data = "") {
|
||||||
def bulbs = [:]
|
def msg = "Bulbs list not processed. Only while in settings menu."
|
||||||
log.trace "Adding bulbs to state..."
|
log.trace "Here: $hub, $data"
|
||||||
state.bridgeProcessedLightList = true
|
if (state.inBulbDiscovery) {
|
||||||
evt.jsonData.each { k,v ->
|
def bulbs = [:]
|
||||||
log.trace "$k: $v"
|
def logg = ""
|
||||||
if (v instanceof Map) {
|
log.trace "Adding bulbs to state..."
|
||||||
bulbs[k] = [id: k, name: v.name, type: v.type, hub:evt.value]
|
state.bridgeProcessedLightList = true
|
||||||
}
|
def object = new groovy.json.JsonSlurper().parseText(data)
|
||||||
}
|
object.each { k,v ->
|
||||||
state.bulbs = bulbs
|
if (v instanceof Map)
|
||||||
log.info "${bulbs.size()} bulbs found"
|
bulbs[k] = [id: k, name: v.name, type: v.type, hub:hub]
|
||||||
|
}
|
||||||
|
state.bulbs = bulbs
|
||||||
|
msg = "${bulbs.size()} bulbs found. $state.bulbs"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
def addBulbs() {
|
def addBulbs() {
|
||||||
@@ -294,7 +306,7 @@ def addBulbs() {
|
|||||||
} else {
|
} else {
|
||||||
d = addChildDevice("smartthings", "Hue Bulb", dni, newHueBulb?.value.hub, ["label":newHueBulb?.value.name])
|
d = addChildDevice("smartthings", "Hue Bulb", dni, newHueBulb?.value.hub, ["label":newHueBulb?.value.name])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
//backwards compatable
|
//backwards compatable
|
||||||
newHueBulb = bulbs.find { (app.id + "/" + it.id) == dni }
|
newHueBulb = bulbs.find { (app.id + "/" + it.id) == dni }
|
||||||
d = addChildDevice("smartthings", "Hue Bulb", dni, newHueBulb?.hub, ["label":newHueBulb?.name])
|
d = addChildDevice("smartthings", "Hue Bulb", dni, newHueBulb?.hub, ["label":newHueBulb?.name])
|
||||||
@@ -322,7 +334,7 @@ def addBridge() {
|
|||||||
def d = getChildDevice(selectedHue)
|
def d = getChildDevice(selectedHue)
|
||||||
if(!d) {
|
if(!d) {
|
||||||
// compatibility with old devices
|
// compatibility with old devices
|
||||||
def newbridge = true
|
def newbridge = true
|
||||||
childDevices.each {
|
childDevices.each {
|
||||||
if (it.getDeviceDataByName("mac")) {
|
if (it.getDeviceDataByName("mac")) {
|
||||||
def newDNI = "${it.getDeviceDataByName("mac")}"
|
def newDNI = "${it.getDeviceDataByName("mac")}"
|
||||||
@@ -332,22 +344,27 @@ def addBridge() {
|
|||||||
it.setDeviceNetworkId("${newDNI}")
|
it.setDeviceNetworkId("${newDNI}")
|
||||||
if (oldDNI == selectedHue)
|
if (oldDNI == selectedHue)
|
||||||
app.updateSetting("selectedHue", newDNI)
|
app.updateSetting("selectedHue", newDNI)
|
||||||
newbridge = false
|
newbridge = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newbridge) {
|
if (newbridge) {
|
||||||
d = addChildDevice("smartthings", "Hue Bridge", selectedHue, vbridge.value.hub)
|
d = addChildDevice("smartthings", "Hue Bridge", selectedHue, vbridge.value.hub)
|
||||||
log.debug "created ${d.displayName} with id ${d.deviceNetworkId}"
|
log.debug "created ${d.displayName} with id ${d.deviceNetworkId}"
|
||||||
def childDevice = getChildDevice(d.deviceNetworkId)
|
def childDevice = getChildDevice(d.deviceNetworkId)
|
||||||
childDevice.sendEvent(name: "serialNumber", value: vbridge.value.serialNumber)
|
childDevice.sendEvent(name: "serialNumber", value: vbridge.value.serialNumber)
|
||||||
if (vbridge.value.ip && vbridge.value.port) {
|
if (vbridge.value.ip && vbridge.value.port) {
|
||||||
if (vbridge.value.ip.contains("."))
|
if (vbridge.value.ip.contains(".")) {
|
||||||
childDevice.sendEvent(name: "networkAddress", value: vbridge.value.ip + ":" + vbridge.value.port)
|
childDevice.sendEvent(name: "networkAddress", value: vbridge.value.ip + ":" + vbridge.value.port)
|
||||||
else
|
childDevice.updateDataValue("networkAddress", vbridge.value.ip + ":" + vbridge.value.port)
|
||||||
childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port))
|
} else {
|
||||||
} else
|
childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port))
|
||||||
|
childDevice.updateDataValue("networkAddress", convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.networkAddress) + ":" + convertHexToInt(vbridge.value.deviceAddress))
|
childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.networkAddress) + ":" + convertHexToInt(vbridge.value.deviceAddress))
|
||||||
|
childDevice.updateDataValue("networkAddress", convertHexToIP(vbridge.value.networkAddress) + ":" + convertHexToInt(vbridge.value.deviceAddress))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug "found ${d.displayName} with id $selectedHue already exists"
|
log.debug "found ${d.displayName} with id $selectedHue already exists"
|
||||||
@@ -355,7 +372,6 @@ def addBridge() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def locationHandler(evt) {
|
def locationHandler(evt) {
|
||||||
def description = evt.description
|
def description = evt.description
|
||||||
log.trace "Location: $description"
|
log.trace "Location: $description"
|
||||||
@@ -454,17 +470,13 @@ def locationHandler(evt) {
|
|||||||
|
|
||||||
def doDeviceSync(){
|
def doDeviceSync(){
|
||||||
log.trace "Doing Hue Device Sync!"
|
log.trace "Doing Hue Device Sync!"
|
||||||
|
|
||||||
//shrink the large bulb lists
|
|
||||||
convertBulbListToMap()
|
convertBulbListToMap()
|
||||||
|
|
||||||
poll()
|
poll()
|
||||||
|
try {
|
||||||
if(!state.subscribe) {
|
|
||||||
subscribe(location, null, locationHandler, [filterEvents:false])
|
subscribe(location, null, locationHandler, [filterEvents:false])
|
||||||
state.subscribe = true
|
} catch (all) {
|
||||||
}
|
log.trace "Subscription already exist"
|
||||||
|
}
|
||||||
discoverBridges()
|
discoverBridges()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,44 +485,49 @@ def doDeviceSync(){
|
|||||||
/////////////////////////////////////
|
/////////////////////////////////////
|
||||||
|
|
||||||
def parse(childDevice, description) {
|
def parse(childDevice, description) {
|
||||||
def parsedEvent = parseLanMessage(description)
|
def parsedEvent = parseLanMessage(description)
|
||||||
if (parsedEvent.headers && parsedEvent.body) {
|
if (parsedEvent.headers && parsedEvent.body) {
|
||||||
def headerString = parsedEvent.headers.toString()
|
def headerString = parsedEvent.headers.toString()
|
||||||
if (headerString?.contains("json")) {
|
def bodyString = parsedEvent.body.toString()
|
||||||
def body = new groovy.json.JsonSlurper().parseText(parsedEvent.body)
|
if (headerString?.contains("json")) {
|
||||||
if (body instanceof java.util.HashMap)
|
def body
|
||||||
{ //poll response
|
try {
|
||||||
|
body = new groovy.json.JsonSlurper().parseText(bodyString)
|
||||||
|
} catch (all) {
|
||||||
|
log.warn "Parsing Body failed - trying again..."
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
if (body instanceof java.util.HashMap) {
|
||||||
|
//poll response
|
||||||
def bulbs = getChildDevices()
|
def bulbs = getChildDevices()
|
||||||
//for each bulb
|
|
||||||
for (bulb in body) {
|
for (bulb in body) {
|
||||||
def d = bulbs.find{it.deviceNetworkId == "${app.id}/${bulb.key}"}
|
def d = bulbs.find{it.deviceNetworkId == "${app.id}/${bulb.key}"}
|
||||||
if (d) {
|
if (d) {
|
||||||
if (bulb.value.state?.reachable) {
|
if (bulb.value.state?.reachable) {
|
||||||
sendEvent(d.deviceNetworkId, [name: "switch", value: bulb.value?.state?.on ? "on" : "off"])
|
sendEvent(d.deviceNetworkId, [name: "switch", value: bulb.value?.state?.on ? "on" : "off"])
|
||||||
sendEvent(d.deviceNetworkId, [name: "level", value: Math.round(bulb.value.state.bri * 100 / 255)])
|
sendEvent(d.deviceNetworkId, [name: "level", value: Math.round(bulb.value.state.bri * 100 / 255)])
|
||||||
if (bulb.value.state.sat) {
|
if (bulb.value.state.sat) {
|
||||||
def hue = Math.min(Math.round(bulb.value.state.hue * 100 / 65535), 65535) as int
|
def hue = Math.min(Math.round(bulb.value.state.hue * 100 / 65535), 65535) as int
|
||||||
def sat = Math.round(bulb.value.state.sat * 100 / 255) as int
|
def sat = Math.round(bulb.value.state.sat * 100 / 255) as int
|
||||||
def hex = colorUtil.hslToHex(hue, sat)
|
def hex = colorUtil.hslToHex(hue, sat)
|
||||||
sendEvent(d.deviceNetworkId, [name: "color", value: hex])
|
sendEvent(d.deviceNetworkId, [name: "color", value: hex])
|
||||||
sendEvent(d.deviceNetworkId, [name: "hue", value: hue])
|
sendEvent(d.deviceNetworkId, [name: "hue", value: hue])
|
||||||
sendEvent(d.deviceNetworkId, [name: "saturation", value: sat])
|
sendEvent(d.deviceNetworkId, [name: "saturation", value: sat])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sendEvent(d.deviceNetworkId, [name: "switch", value: "off"])
|
sendEvent(d.deviceNetworkId, [name: "switch", value: "off"])
|
||||||
sendEvent(d.deviceNetworkId, [name: "level", value: 100])
|
sendEvent(d.deviceNetworkId, [name: "level", value: 100])
|
||||||
if (bulb.value.state.sat) {
|
if (bulb.value.state.sat) {
|
||||||
def hue = 23
|
def hue = 23
|
||||||
def sat = 56
|
def sat = 56
|
||||||
def hex = colorUtil.hslToHex(23, 56)
|
def hex = colorUtil.hslToHex(23, 56)
|
||||||
sendEvent(d.deviceNetworkId, [name: "color", value: hex])
|
sendEvent(d.deviceNetworkId, [name: "color", value: hex])
|
||||||
sendEvent(d.deviceNetworkId, [name: "hue", value: hue])
|
sendEvent(d.deviceNetworkId, [name: "hue", value: hue])
|
||||||
sendEvent(d.deviceNetworkId, [name: "saturation", value: sat])
|
sendEvent(d.deviceNetworkId, [name: "saturation", value: sat])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{ //put response
|
{ //put response
|
||||||
@@ -559,25 +576,25 @@ def parse(childDevice, description) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug "parse - got something other than headers,body..."
|
log.debug "parse - got something other than headers,body..."
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def on(childDevice, transition = 4) {
|
def on(childDevice, transition_deprecated = 0) {
|
||||||
log.debug "Executing 'on'"
|
log.debug "Executing 'on'"
|
||||||
// Assume bulb is off if no current state is found for level to avoid bulbs getting stuck in off after initial discovery
|
def percent = childDevice.device?.currentValue("level") as Integer
|
||||||
def percent = childDevice.device?.currentValue("level") as Integer ?: 0
|
|
||||||
def level = Math.min(Math.round(percent * 255 / 100), 255)
|
def level = Math.min(Math.round(percent * 255 / 100), 255)
|
||||||
put("lights/${getId(childDevice)}/state", [bri: level, on: true, transitiontime: transition])
|
put("lights/${getId(childDevice)}/state", [bri: level, on: true])
|
||||||
return "level: $percent"
|
return "level: $percent"
|
||||||
}
|
}
|
||||||
|
|
||||||
def off(childDevice, transition = 4) {
|
def off(childDevice, transition_deprecated = 0) {
|
||||||
log.debug "Executing 'off'"
|
log.debug "Executing 'off'"
|
||||||
put("lights/${getId(childDevice)}/state", [on: false, transitiontime: transition])
|
put("lights/${getId(childDevice)}/state", [on: false])
|
||||||
|
return "level: 0"
|
||||||
}
|
}
|
||||||
|
|
||||||
def setLevel(childDevice, percent) {
|
def setLevel(childDevice, percent) {
|
||||||
@@ -598,19 +615,21 @@ def setHue(childDevice, percent) {
|
|||||||
put("lights/${getId(childDevice)}/state", [hue: level])
|
put("lights/${getId(childDevice)}/state", [hue: level])
|
||||||
}
|
}
|
||||||
|
|
||||||
def setColor(childDevice, color, alert = "none", transition = 4) {
|
def setColor(childDevice, huesettings, alert_deprecated = "", transition_deprecated = 0) {
|
||||||
log.debug "Executing 'setColor($color)'"
|
log.debug "Executing 'setColor($huesettings)'"
|
||||||
def hue = Math.min(Math.round(color.hue * 65535 / 100), 65535)
|
def hue = Math.min(Math.round(huesettings.hue * 65535 / 100), 65535)
|
||||||
def sat = Math.min(Math.round(color.saturation * 255 / 100), 255)
|
def sat = Math.min(Math.round(huesettings.saturation * 255 / 100), 255)
|
||||||
|
def alert = huesettings.alert ? huesettings.alert : "none"
|
||||||
|
def transition = huesettings.transition ? huesettings.transition : 4
|
||||||
|
|
||||||
def value = [sat: sat, hue: hue, alert: alert, transitiontime: transition]
|
def value = [sat: sat, hue: hue, alert: alert, transitiontime: transition]
|
||||||
if (color.level != null) {
|
if (huesettings.level != null) {
|
||||||
value.bri = Math.min(Math.round(color.level * 255 / 100), 255)
|
value.bri = Math.min(Math.round(huesettings.level * 255 / 100), 255)
|
||||||
value.on = value.bri > 0
|
value.on = value.bri > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (color.switch) {
|
if (huesettings.switch) {
|
||||||
value.on = color.switch == "on"
|
value.on = huesettings.switch == "on"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug "sending command $value"
|
log.debug "sending command $value"
|
||||||
@@ -640,15 +659,19 @@ private getId(childDevice) {
|
|||||||
private poll() {
|
private poll() {
|
||||||
def host = getBridgeIP()
|
def host = getBridgeIP()
|
||||||
def uri = "/api/${state.username}/lights/"
|
def uri = "/api/${state.username}/lights/"
|
||||||
log.debug "GET: $host$uri"
|
try {
|
||||||
sendHubCommand(new physicalgraph.device.HubAction("""GET ${uri} HTTP/1.1
|
sendHubCommand(new physicalgraph.device.HubAction("""GET ${uri} HTTP/1.1
|
||||||
HOST: ${host}
|
HOST: ${host}
|
||||||
|
|
||||||
""", physicalgraph.device.Protocol.LAN, selectedHue))
|
""", physicalgraph.device.Protocol.LAN, selectedHue))
|
||||||
|
} catch (all) {
|
||||||
|
log.warn "Parsing Body failed - trying again..."
|
||||||
|
doDeviceSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private put(path, body) {
|
private put(path, body) {
|
||||||
def host = getBridgeIP()
|
def host = getBridgeIP()
|
||||||
def uri = "/api/${state.username}/$path"
|
def uri = "/api/${state.username}/$path"
|
||||||
def bodyJSON = new groovy.json.JsonBuilder(body).toString()
|
def bodyJSON = new groovy.json.JsonBuilder(body).toString()
|
||||||
def length = bodyJSON.getBytes().size().toString()
|
def length = bodyJSON.getBytes().size().toString()
|
||||||
@@ -668,9 +691,13 @@ ${bodyJSON}
|
|||||||
private getBridgeIP() {
|
private getBridgeIP() {
|
||||||
def host = null
|
def host = null
|
||||||
if (selectedHue) {
|
if (selectedHue) {
|
||||||
def d = getChildDevice(dni)
|
def d = getChildDevice(selectedHue)
|
||||||
if (d)
|
if (d) {
|
||||||
host = d.latestState('networkAddress').stringValue
|
if (d.getDeviceDataByName("networkAddress"))
|
||||||
|
host = d.getDeviceDataByName("networkAddress")
|
||||||
|
else
|
||||||
|
host = d.latestState('networkAddress').stringValue
|
||||||
|
}
|
||||||
if (host == null || host == "") {
|
if (host == null || host == "") {
|
||||||
def serialNumber = selectedHue
|
def serialNumber = selectedHue
|
||||||
def bridge = getHueBridges().find { it?.value?.serialNumber?.equalsIgnoreCase(serialNumber) }?.value
|
def bridge = getHueBridges().find { it?.value?.serialNumber?.equalsIgnoreCase(serialNumber) }?.value
|
||||||
@@ -681,9 +708,9 @@ private getBridgeIP() {
|
|||||||
host = "${convertHexToIP(bridge?.ip)}:${convertHexToInt(bridge?.port)}"
|
host = "${convertHexToIP(bridge?.ip)}:${convertHexToInt(bridge?.port)}"
|
||||||
} else if (bridge?.networkAddress && bridge?.deviceAddress)
|
} else if (bridge?.networkAddress && bridge?.deviceAddress)
|
||||||
host = "${convertHexToIP(bridge?.networkAddress)}:${convertHexToInt(bridge?.deviceAddress)}"
|
host = "${convertHexToIP(bridge?.networkAddress)}:${convertHexToInt(bridge?.deviceAddress)}"
|
||||||
}
|
}
|
||||||
log.trace "Bridge: $selectedHue - Host: $host"
|
log.trace "Bridge: $selectedHue - Host: $host"
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
394
smartapps/smartthings/lifx-connect.src/lifx-connect.groovy
Normal file
394
smartapps/smartthings/lifx-connect.src/lifx-connect.groovy
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
/**
|
||||||
|
* LIFX
|
||||||
|
*
|
||||||
|
* Copyright 2015 LIFX
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
definition(
|
||||||
|
name: "LIFX (Connect)",
|
||||||
|
namespace: "smartthings",
|
||||||
|
author: "LIFX",
|
||||||
|
description: "Allows you to use LIFX smart light bulbs with SmartThings.",
|
||||||
|
category: "Convenience",
|
||||||
|
iconUrl: "https://cloud.lifx.com/images/lifx.png",
|
||||||
|
iconX2Url: "https://cloud.lifx.com/images/lifx.png",
|
||||||
|
iconX3Url: "https://cloud.lifx.com/images/lifx.png",
|
||||||
|
oauth: true) {
|
||||||
|
appSetting "clientId"
|
||||||
|
appSetting "clientSecret"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
preferences {
|
||||||
|
page(name: "Credentials", title: "LIFX", content: "authPage", install: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
mappings {
|
||||||
|
path("/receivedToken") { action: [ POST: "oauthReceivedToken", GET: "oauthReceivedToken"] }
|
||||||
|
path("/receiveToken") { action: [ POST: "oauthReceiveToken", GET: "oauthReceiveToken"] }
|
||||||
|
path("/hookCallback") { action: [ POST: "hookEventHandler", GET: "hookEventHandler"] }
|
||||||
|
path("/oauth/callback") { action: [ GET: "oauthCallback" ] }
|
||||||
|
path("/oauth/initialize") { action: [ GET: "oauthInit"] }
|
||||||
|
path("/test") { action: [ GET: "oauthSuccess" ] }
|
||||||
|
}
|
||||||
|
|
||||||
|
def getServerUrl() { return "https://graph.api.smartthings.com" }
|
||||||
|
def apiURL(path = '/') { return "https://api.lifx.com/v1beta1${path}" }
|
||||||
|
def buildRedirectUrl(page) {
|
||||||
|
return "${serverUrl}/api/token/${state.accessToken}/smartapps/installations/${app.id}/${page}"
|
||||||
|
}
|
||||||
|
|
||||||
|
def authPage() {
|
||||||
|
log.debug "authPage"
|
||||||
|
if (!state.lifxAccessToken) {
|
||||||
|
log.debug "no LIFX access token"
|
||||||
|
// This is the SmartThings access token
|
||||||
|
if (!state.accessToken) {
|
||||||
|
log.debug "no access token, create access token"
|
||||||
|
createAccessToken() // predefined method
|
||||||
|
}
|
||||||
|
def description = "Tap to enter LIFX credentials"
|
||||||
|
def redirectUrl = "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}" // this triggers oauthInit() below
|
||||||
|
log.debug "app id: ${app.id}"
|
||||||
|
log.debug "redirect url: ${redirectUrl}"
|
||||||
|
return dynamicPage(name: "Credentials", title: "Connect to LIFX", nextPage: null, uninstall: true, install:false) {
|
||||||
|
section {
|
||||||
|
href(url:redirectUrl, required:true, title:"Connect to LIFX", description:"Tap here to connect your LIFX account")
|
||||||
|
// href(url:buildRedirectUrl("test"), title: "Message test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.debug "have LIFX access token"
|
||||||
|
|
||||||
|
def options = locationOptions() ?: []
|
||||||
|
def count = options.size()
|
||||||
|
def refreshInterval = 3
|
||||||
|
|
||||||
|
return dynamicPage(name:"Credentials", title:"Select devices...", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) {
|
||||||
|
section("Select your location") {
|
||||||
|
input "selectedLocationId", "enum", required:true, title:"Select location (${count} found)", multiple:false, options:options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// OAuth
|
||||||
|
|
||||||
|
def oauthInit() {
|
||||||
|
def oauthParams = [client_id: "${appSettings.clientId}", scope: "remote_control:all", response_type: "code" ]
|
||||||
|
log.info("Redirecting user to OAuth setup")
|
||||||
|
redirect(location: "https://cloud.lifx.com/oauth/authorize?${toQueryString(oauthParams)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
def oauthCallback() {
|
||||||
|
def redirectUrl = null
|
||||||
|
if (params.authQueryString) {
|
||||||
|
redirectUrl = URLDecoder.decode(params.authQueryString.replaceAll(".+&redirect_url=", ""))
|
||||||
|
} else {
|
||||||
|
log.warn "No authQueryString"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.lifxAccessToken) {
|
||||||
|
log.debug "Access token already exists"
|
||||||
|
success()
|
||||||
|
} else {
|
||||||
|
def code = params.code
|
||||||
|
if (code) {
|
||||||
|
if (code.size() > 6) {
|
||||||
|
// LIFX code
|
||||||
|
log.debug "Exchanging code for access token"
|
||||||
|
oauthReceiveToken(redirectUrl)
|
||||||
|
} else {
|
||||||
|
// Initiate the LIFX OAuth flow.
|
||||||
|
oauthInit()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.debug "This code should be unreachable"
|
||||||
|
success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def oauthReceiveToken(redirectUrl = null) {
|
||||||
|
|
||||||
|
log.debug "receiveToken - params: ${params}"
|
||||||
|
def oauthParams = [ client_id: "${appSettings.clientId}", client_secret: "${appSettings.clientSecret}", grant_type: "authorization_code", code: params.code, scope: params.scope ] // how is params.code valid here?
|
||||||
|
def params = [
|
||||||
|
uri: "https://cloud.lifx.com/oauth/token",
|
||||||
|
body: oauthParams,
|
||||||
|
headers: [
|
||||||
|
"User-Agent": "SmartThings Integration"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
httpPost(params) { response ->
|
||||||
|
state.lifxAccessToken = response.data.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.lifxAccessToken) {
|
||||||
|
oauthSuccess()
|
||||||
|
} else {
|
||||||
|
oauthFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def oauthSuccess() {
|
||||||
|
def message = """
|
||||||
|
<p>Your LIFX Account is now connected to SmartThings!</p>
|
||||||
|
<p>Click 'Done' to finish setup.</p>
|
||||||
|
"""
|
||||||
|
oauthConnectionStatus(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
def oauthFailure() {
|
||||||
|
def message = """
|
||||||
|
<p>The connection could not be established!</p>
|
||||||
|
<p>Click 'Done' to return to the menu.</p>
|
||||||
|
"""
|
||||||
|
oauthConnectionStatus(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
def oauthReceivedToken() {
|
||||||
|
def message = """
|
||||||
|
<p>Your LIFX Account is already connected to SmartThings!</p>
|
||||||
|
<p>Click 'Done' to finish setup.</p>
|
||||||
|
"""
|
||||||
|
oauthConnectionStatus(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
def oauthConnectionStatus(message, redirectUrl = null) {
|
||||||
|
def redirectHtml = ""
|
||||||
|
if (redirectUrl) {
|
||||||
|
redirectHtml = """
|
||||||
|
<meta http-equiv="refresh" content="3; url=${redirectUrl}" />
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
def html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<title>SmartThings Connection</title>
|
||||||
|
<style type="text/css">
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Swiss 721 W01 Thin';
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Swiss 721 W01 Light';
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
width: 280;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
img:nth-child(2) {
|
||||||
|
margin: 0 15px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-family: 'Swiss 721 W01 Thin';
|
||||||
|
text-align: center;
|
||||||
|
color: #666666;
|
||||||
|
padding: 0 20px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
font-family: 'Swiss 721 W01 Light';
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
${redirectHtml}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<img src='https://cloud.lifx.com/images/lifx.png' alt='LIFX icon' width='100'/>
|
||||||
|
<img src='https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png' alt='connected device icon' width="40"/>
|
||||||
|
<img src='https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png' alt='SmartThings logo' width="100"/>
|
||||||
|
<p>
|
||||||
|
${message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
render contentType: 'text/html', data: html
|
||||||
|
}
|
||||||
|
|
||||||
|
String toQueryString(Map m) {
|
||||||
|
return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
|
||||||
|
}
|
||||||
|
|
||||||
|
// App lifecycle hooks
|
||||||
|
|
||||||
|
def installed() {
|
||||||
|
enableCallback() // wtf does this do?
|
||||||
|
if (!state.accessToken) {
|
||||||
|
createAccessToken()
|
||||||
|
} else {
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
// Check for new devices and remove old ones every 3 hours
|
||||||
|
runEvery3Hours('updateDevices')
|
||||||
|
}
|
||||||
|
|
||||||
|
// called after settings are changed
|
||||||
|
def updated() {
|
||||||
|
enableCallback() // not sure what this does
|
||||||
|
if (!state.accessToken) {
|
||||||
|
createAccessToken()
|
||||||
|
} else {
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def uninstalled() {
|
||||||
|
log.info("Uninstalling, removing child devices...")
|
||||||
|
unschedule('updateDevices')
|
||||||
|
removeChildDevices(getChildDevices())
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeChildDevices(devices) {
|
||||||
|
devices.each {
|
||||||
|
deleteChildDevice(it.deviceNetworkId) // 'it' is default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// called after Done is hit after selecting a Location
|
||||||
|
def initialize() {
|
||||||
|
log.debug "initialize"
|
||||||
|
updateDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
|
||||||
|
Map apiRequestHeaders() {
|
||||||
|
return ["Authorization": "Bearer ${state.lifxAccessToken}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "SmartThings Integration"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requests
|
||||||
|
|
||||||
|
def logResponse(response) {
|
||||||
|
log.info("Status: ${response.status}")
|
||||||
|
log.info("Body: ${response.data}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Requests
|
||||||
|
// logObject is because log doesn't work if this method is being called from a Device
|
||||||
|
def logErrors(options = [errorReturn: null, logObject: log], Closure c) {
|
||||||
|
try {
|
||||||
|
return c()
|
||||||
|
} catch (groovyx.net.http.HttpResponseException e) {
|
||||||
|
options.logObject.error("got error: ${e}, body: ${e.getResponse().getData()}")
|
||||||
|
if (e.statusCode == 401) { // token is expired
|
||||||
|
state.remove("lifxAccessToken")
|
||||||
|
options.logObject.warn "Access token is not valid"
|
||||||
|
}
|
||||||
|
return options.errerReturn
|
||||||
|
} catch (java.net.SocketTimeoutException e) {
|
||||||
|
options.logObject.warn "Connection timed out, not much we can do here"
|
||||||
|
return options.errerReturn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def apiGET(path) {
|
||||||
|
httpGet(uri: apiURL(path), headers: apiRequestHeaders()) {response ->
|
||||||
|
logResponse(response)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def apiPUT(path, body = [:]) {
|
||||||
|
log.debug("Beginning API PUT: ${path}, ${body}")
|
||||||
|
httpPutJson(uri: apiURL(path), body: new groovy.json.JsonBuilder(body).toString(), headers: apiRequestHeaders(), ) {response ->
|
||||||
|
logResponse(response)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def devicesList(selector = '') {
|
||||||
|
logErrors([]) {
|
||||||
|
def resp = apiGET("/lights/${selector}")
|
||||||
|
if (resp.status == 200) {
|
||||||
|
return resp.data
|
||||||
|
} else {
|
||||||
|
log.error("Non-200 from device list call. ${resp.status} ${resp.data}")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map locationOptions() {
|
||||||
|
|
||||||
|
def options = [:]
|
||||||
|
def devices = devicesList()
|
||||||
|
devices.each { device ->
|
||||||
|
options[device.location.id] = device.location.name
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
def devicesInLocation() {
|
||||||
|
return devicesList("location_id:${settings.selectedLocationId}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensures the devices list is up to date
|
||||||
|
def updateDevices() {
|
||||||
|
if (!state.devices) {
|
||||||
|
state.devices = [:]
|
||||||
|
}
|
||||||
|
def devices = devicesInLocation()
|
||||||
|
def deviceIds = devices*.id
|
||||||
|
devices.each { device ->
|
||||||
|
def childDevice = getChildDevice(device.id)
|
||||||
|
if (!childDevice) {
|
||||||
|
log.info("Adding device ${device.id}: ${device.capabilities}")
|
||||||
|
def data = [
|
||||||
|
label: device.label,
|
||||||
|
level: sprintf("%f", (device.brightness ?: 1) * 100),
|
||||||
|
switch: device.connected ? device.power : "unreachable",
|
||||||
|
colorTemperature: device.color.kelvin
|
||||||
|
]
|
||||||
|
if (device.capabilities.has_color) {
|
||||||
|
data["color"] = colorUtil.hslToHex((device.color.hue / 3.6) as int, (device.color.saturation * 100) as int)
|
||||||
|
data["hue"] = device.color.hue / 3.6
|
||||||
|
data["saturation"] = device.color.saturation * 100
|
||||||
|
childDevice = addChildDevice("smartthings", "LIFX Color Bulb", device.id, null, data)
|
||||||
|
} else {
|
||||||
|
childDevice = addChildDevice("smartthings", "LIFX White Bulb", device.id, null, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getChildDevices().findAll { !deviceIds.contains(it.deviceNetworkId) }.each {
|
||||||
|
log.info("Deleting ${it.deviceNetworkId}")
|
||||||
|
deleteChildDevice(it.deviceNetworkId)
|
||||||
|
}
|
||||||
|
runIn(1, 'refreshDevices') // Asynchronously refresh devices so we don't block
|
||||||
|
}
|
||||||
|
|
||||||
|
def refreshDevices() {
|
||||||
|
log.info("Refreshing all devices...")
|
||||||
|
getChildDevices().each { device ->
|
||||||
|
device.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
definition(
|
definition(
|
||||||
name: "Logitech Harmony (Connect)",
|
name: "Logitech Harmony (Connect)",
|
||||||
namespace: "smartthings",
|
namespace: "smartthings",
|
||||||
author: "Juan Pablo Risso",
|
author: "SmartThings",
|
||||||
description: "Allows you to integrate your Logitech Harmony account with SmartThings.",
|
description: "Allows you to integrate your Logitech Harmony account with SmartThings.",
|
||||||
category: "SmartThings Labs",
|
category: "SmartThings Labs",
|
||||||
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/harmony.png",
|
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/harmony.png",
|
||||||
@@ -394,7 +394,9 @@ def discovery() {
|
|||||||
} catch (java.net.SocketTimeoutException e) {
|
} catch (java.net.SocketTimeoutException e) {
|
||||||
log.warn "Connection to the hub timed out. Please restart the hub and try again."
|
log.warn "Connection to the hub timed out. Please restart the hub and try again."
|
||||||
state.resethub = true
|
state.resethub = true
|
||||||
}
|
} catch (e) {
|
||||||
|
log.warn "Hostname in certificate didn't match. Please try again later."
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +461,9 @@ def activity(dni,mode) {
|
|||||||
msg = ex
|
msg = ex
|
||||||
state.aux = 0
|
state.aux = 0
|
||||||
}
|
}
|
||||||
}
|
} catch(Exception ex) {
|
||||||
|
msg = ex
|
||||||
|
}
|
||||||
runIn(10, "poll", [overwrite: true])
|
runIn(10, "poll", [overwrite: true])
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
@@ -517,7 +521,9 @@ def poll() {
|
|||||||
state.remove("HarmonyAccessToken")
|
state.remove("HarmonyAccessToken")
|
||||||
return "Harmony Access token has expired"
|
return "Harmony Access token has expired"
|
||||||
}
|
}
|
||||||
}
|
} catch(Exception e) {
|
||||||
|
log.trace e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +556,9 @@ def getActivityList() {
|
|||||||
log.trace e
|
log.trace e
|
||||||
} catch (java.net.SocketTimeoutException e) {
|
} catch (java.net.SocketTimeoutException e) {
|
||||||
log.trace e
|
log.trace e
|
||||||
}
|
} catch(Exception e) {
|
||||||
|
log.trace e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
@@ -565,9 +573,9 @@ def getActivityName(activity,hubId) {
|
|||||||
httpGet(uri: url, headers: ["Accept": "application/json"]) {response ->
|
httpGet(uri: url, headers: ["Accept": "application/json"]) {response ->
|
||||||
actname = response.data.data.activities[activity].name
|
actname = response.data.data.activities[activity].name
|
||||||
}
|
}
|
||||||
} catch (groovyx.net.http.HttpResponseException e) {
|
} catch(Exception e) {
|
||||||
log.trace e
|
log.trace e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return actname
|
return actname
|
||||||
}
|
}
|
||||||
@@ -585,9 +593,9 @@ def getActivityId(activity,hubId) {
|
|||||||
actid = it.key
|
actid = it.key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (groovyx.net.http.HttpResponseException e) {
|
} catch(Exception e) {
|
||||||
log.trace e
|
log.trace e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return actid
|
return actid
|
||||||
}
|
}
|
||||||
@@ -602,9 +610,9 @@ def getHubName(hubId) {
|
|||||||
httpGet(uri: url, headers: ["Accept": "application/json"]) {response ->
|
httpGet(uri: url, headers: ["Accept": "application/json"]) {response ->
|
||||||
hubname = response.data.data.name
|
hubname = response.data.data.name
|
||||||
}
|
}
|
||||||
} catch (groovyx.net.http.HttpResponseException e) {
|
} catch(Exception e) {
|
||||||
log.trace e
|
log.trace e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hubname
|
return hubname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,12 +79,9 @@ def scenePage(params=[:]) {
|
|||||||
href "devicePage", title: "Show Device States", params: [sceneId:sceneId], description: "", state: sceneIsDefined(sceneId) ? "complete" : "incomplete"
|
href "devicePage", title: "Show Device States", params: [sceneId:sceneId], description: "", state: sceneIsDefined(sceneId) ? "complete" : "incomplete"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneId == currentSceneId) {
|
section {
|
||||||
section {
|
href "saveStatesPage", title: "Record Current Device States", params: [sceneId:sceneId], description: ""
|
||||||
href "saveStatesPage", title: "Record Current Device States", params: [sceneId:sceneId], description: ""
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +222,7 @@ private restoreStates(sceneId) {
|
|||||||
if (type == "level") {
|
if (type == "level") {
|
||||||
log.debug "${light.displayName} level is '$level'"
|
log.debug "${light.displayName} level is '$level'"
|
||||||
if (level != null) {
|
if (level != null) {
|
||||||
light.setLevel(value)
|
light.setLevel(level)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (type == "color") {
|
else if (type == "color") {
|
||||||
|
|||||||
@@ -346,18 +346,20 @@ private getSensorJSON(id, key) {
|
|||||||
|
|
||||||
def sensorUrl = "${wattvisionBaseURL()}/partners/smartthings/sensor_list?api_id=${id}&api_key=${key}"
|
def sensorUrl = "${wattvisionBaseURL()}/partners/smartthings/sensor_list?api_id=${id}&api_key=${key}"
|
||||||
|
|
||||||
httpGet(uri: sensorUrl) { response ->
|
httpGet(uri: sensorUrl) { response ->
|
||||||
|
|
||||||
def json = new org.json.JSONObject(response.data)
|
def sensors = [:]
|
||||||
|
|
||||||
state.sensors = json
|
response.data.each { sensorId, sensorName ->
|
||||||
|
sensors[sensorId] = sensorName
|
||||||
json.each { sensorId, sensorName ->
|
|
||||||
createChild(sensorId, sensorName)
|
createChild(sensorId, sensorName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.sensors = sensors
|
||||||
|
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def createChild(sensorId, sensorName) {
|
def createChild(sensorId, sensorName) {
|
||||||
|
|||||||
@@ -0,0 +1,775 @@
|
|||||||
|
/**
|
||||||
|
* Title: Withings Service Manager
|
||||||
|
* Description: Connect Your Withings Devices
|
||||||
|
*
|
||||||
|
* Author: steve
|
||||||
|
* Date: 1/9/15
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Copyright 2015 steve
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
definition(
|
||||||
|
name: "Withings Manager",
|
||||||
|
namespace: "smartthings",
|
||||||
|
author: "SmartThings",
|
||||||
|
description: "Connect With Withings",
|
||||||
|
category: "",
|
||||||
|
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png",
|
||||||
|
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png",
|
||||||
|
iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png",
|
||||||
|
oauth: true
|
||||||
|
) {
|
||||||
|
appSetting "consumerKey"
|
||||||
|
appSetting "consumerSecret"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// PAGES
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
preferences {
|
||||||
|
page(name: "authPage")
|
||||||
|
}
|
||||||
|
|
||||||
|
def authPage() {
|
||||||
|
|
||||||
|
def installOptions = false
|
||||||
|
def description = "Required (tap to set)"
|
||||||
|
def authState
|
||||||
|
|
||||||
|
if (oauth_token()) {
|
||||||
|
// TODO: Check if it's valid
|
||||||
|
if (true) {
|
||||||
|
description = "Saved (tap to change)"
|
||||||
|
installOptions = true
|
||||||
|
authState = "complete"
|
||||||
|
} else {
|
||||||
|
// Worth differentiating here? (no longer valid vs. non-existent state.externalAuthToken?)
|
||||||
|
description = "Required (tap to set)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
dynamicPage(name: "authPage", install: installOptions, uninstall: true) {
|
||||||
|
section {
|
||||||
|
|
||||||
|
if (installOptions) {
|
||||||
|
input(name: "withingsLabel", type: "text", title: "Add a name", description: null, required: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
href url: shortUrl("authenticate"), style: "embedded", required: false, title: "Authenticate with Withings", description: description, state: authState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// MAPPINGS
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
mappings {
|
||||||
|
path("/authenticate") {
|
||||||
|
action:
|
||||||
|
[
|
||||||
|
GET: "authenticate"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
path("/x") {
|
||||||
|
action:
|
||||||
|
[
|
||||||
|
GET: "exchangeTokenFromWithings"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
path("/n") {
|
||||||
|
action:
|
||||||
|
[POST: "notificationReceived"]
|
||||||
|
}
|
||||||
|
|
||||||
|
path("/test/:action") {
|
||||||
|
action:
|
||||||
|
[GET: "test"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test() {
|
||||||
|
"${params.action}"()
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate() {
|
||||||
|
// do not hit userAuthorizationUrl when the page is executed. It will replace oauth_tokens
|
||||||
|
// instead, redirect through here so we know for sure that the user wants to authenticate
|
||||||
|
// plus, the short-lived tokens that are used during authentication are only valid for 2 minutes
|
||||||
|
// so make sure we give the user as much of that 2 minutes as possible to enter their credentials and deal with network latency
|
||||||
|
log.trace "starting Withings authentication flow"
|
||||||
|
redirect location: userAuthorizationUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
def exchangeTokenFromWithings() {
|
||||||
|
// Withings hits us here during the oAuth flow
|
||||||
|
// log.trace "exchangeTokenFromWithings ${params}"
|
||||||
|
atomicState.userid = params.userid // TODO: restructure this for multi-user access
|
||||||
|
exchangeToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
def notificationReceived() {
|
||||||
|
// log.trace "notificationReceived params: ${params}"
|
||||||
|
|
||||||
|
def notificationParams = [
|
||||||
|
startdate: params.startdate,
|
||||||
|
userid : params.userid,
|
||||||
|
enddate : params.enddate,
|
||||||
|
]
|
||||||
|
|
||||||
|
def measures = wGetMeasures(notificationParams)
|
||||||
|
sendMeasureEvents(measures)
|
||||||
|
return [status: 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// HANDLERS
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
|
||||||
|
def installed() {
|
||||||
|
log.debug "Installed with settings: ${settings}"
|
||||||
|
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
def updated() {
|
||||||
|
log.debug "Updated with settings: ${settings}"
|
||||||
|
|
||||||
|
// wRevokeAllNotifications()
|
||||||
|
|
||||||
|
unsubscribe()
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
def initialize() {
|
||||||
|
if (!getChild()) { createChild() }
|
||||||
|
app.updateLabel(withingsLabel)
|
||||||
|
wCreateNotification()
|
||||||
|
backfillMeasures()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// CHILD DEVICE
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
private getChild() {
|
||||||
|
def children = childDevices
|
||||||
|
children.size() ? children.first() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createChild() {
|
||||||
|
def child = addChildDevice("smartthings", "Withings User", userid(), null, [name: app.label, label: withingsLabel])
|
||||||
|
atomicState.child = [dni: child.deviceNetworkId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// URL HELPERS
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def stBaseUrl() {
|
||||||
|
if (!atomicState.serverUrl) {
|
||||||
|
stToken()
|
||||||
|
atomicState.serverUrl = buildActionUrl("").split(/api\//).first()
|
||||||
|
}
|
||||||
|
return atomicState.serverUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
def stToken() {
|
||||||
|
atomicState.accessToken ?: createAccessToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
def shortUrl(path = "", urlParams = [:]) {
|
||||||
|
attachParams("${stBaseUrl()}api/t/${stToken()}/s/${app.id}/${path}", urlParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
def noTokenUrl(path = "", urlParams = [:]) {
|
||||||
|
attachParams("${stBaseUrl()}api/smartapps/installations/${app.id}/${path}", urlParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
def attachParams(url, urlParams = [:]) {
|
||||||
|
[url, toQueryString(urlParams)].findAll().join("?")
|
||||||
|
}
|
||||||
|
|
||||||
|
String toQueryString(Map m = [:]) {
|
||||||
|
// log.trace "toQueryString. URLEncoder will be used on ${m}"
|
||||||
|
return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS MEASURES
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def unixTime(date = new Date()) {
|
||||||
|
def unixTime = date.time / 1000 as int
|
||||||
|
// log.debug "converting ${date.time} to ${unixTime}"
|
||||||
|
unixTime
|
||||||
|
}
|
||||||
|
|
||||||
|
def backfillMeasures() {
|
||||||
|
// log.trace "backfillMeasures"
|
||||||
|
def measureParams = [startdate: unixTime(new Date() - 10)]
|
||||||
|
def measures = wGetMeasures(measureParams)
|
||||||
|
sendMeasureEvents(measures)
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is body measures. // TODO: get activity and others too
|
||||||
|
def wGetMeasures(measureParams = [:]) {
|
||||||
|
def baseUrl = "https://wbsapi.withings.net/measure"
|
||||||
|
def urlParams = [
|
||||||
|
action : "getmeas",
|
||||||
|
userid : userid(),
|
||||||
|
startdate : unixTime(new Date() - 5),
|
||||||
|
enddate : unixTime(),
|
||||||
|
oauth_token: oauth_token()
|
||||||
|
] + measureParams
|
||||||
|
def measureData = fetchDataFromWithings(baseUrl, urlParams)
|
||||||
|
// log.debug "measureData: ${measureData}"
|
||||||
|
measureData.body.measuregrps.collect { parseMeasureGroup(it) }.flatten()
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
[
|
||||||
|
body:[
|
||||||
|
measuregrps:[
|
||||||
|
[
|
||||||
|
category:1, // 1 for real measurements, 2 for user objectives.
|
||||||
|
grpid:310040317,
|
||||||
|
measures:[
|
||||||
|
[
|
||||||
|
unit:0, // Power of ten the "value" parameter should be multiplied to to get the real value. Eg : value = 20 and unit=-1 means the value really is 2.0
|
||||||
|
value:60, // Value for the measure in S.I units (kilogram, meters, etc.). Value should be multiplied by 10 to the power of "unit" (see below) to get the real value.
|
||||||
|
type:11 // 1 : Weight (kg), 4 : Height (meter), 5 : Fat Free Mass (kg), 6 : Fat Ratio (%), 8 : Fat Mass Weight (kg), 9 : Diastolic Blood Pressure (mmHg), 10 : Systolic Blood Pressure (mmHg), 11 : Heart Pulse (bpm), 54 : SP02(%)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
unit:-3,
|
||||||
|
value:-1000,
|
||||||
|
type:18
|
||||||
|
]
|
||||||
|
],
|
||||||
|
date:1422750210,
|
||||||
|
attrib:2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
updatetime:1422750227
|
||||||
|
],
|
||||||
|
status:0
|
||||||
|
]
|
||||||
|
*/
|
||||||
|
|
||||||
|
def sendMeasureEvents(measures) {
|
||||||
|
// log.debug "measures: ${measures}"
|
||||||
|
measures.each {
|
||||||
|
if (it.name && it.value) {
|
||||||
|
sendEvent(userid(), it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def parseMeasureGroup(measureGroup) {
|
||||||
|
long time = measureGroup.date // must be long. INT_MAX is too small
|
||||||
|
time *= 1000
|
||||||
|
measureGroup.measures.collect { parseMeasure(it) + [date: new Date(time)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
def parseMeasure(measure) {
|
||||||
|
// log.debug "parseMeasure($measure)"
|
||||||
|
[
|
||||||
|
name : measureAttribute(measure),
|
||||||
|
value: measureValue(measure)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def measureValue(measure) {
|
||||||
|
def value = measure.value * 10.power(measure.unit)
|
||||||
|
if (measure.type == 1) { // Weight (kg)
|
||||||
|
value *= 2.20462262 // kg to lbs
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
String measureAttribute(measure) {
|
||||||
|
def attribute = ""
|
||||||
|
switch (measure.type) {
|
||||||
|
case 1: attribute = "weight"; break;
|
||||||
|
case 4: attribute = "height"; break;
|
||||||
|
case 5: attribute = "leanMass"; break;
|
||||||
|
case 6: attribute = "fatRatio"; break;
|
||||||
|
case 8: attribute = "fatMass"; break;
|
||||||
|
case 9: attribute = "diastolicPressure"; break;
|
||||||
|
case 10: attribute = "systolicPressure"; break;
|
||||||
|
case 11: attribute = "heartPulse"; break;
|
||||||
|
case 54: attribute = "SP02"; break;
|
||||||
|
}
|
||||||
|
return attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
String measureDescription(measure) {
|
||||||
|
def description = ""
|
||||||
|
switch (measure.type) {
|
||||||
|
case 1: description = "Weight (kg)"; break;
|
||||||
|
case 4: description = "Height (meter)"; break;
|
||||||
|
case 5: description = "Fat Free Mass (kg)"; break;
|
||||||
|
case 6: description = "Fat Ratio (%)"; break;
|
||||||
|
case 8: description = "Fat Mass Weight (kg)"; break;
|
||||||
|
case 9: description = "Diastolic Blood Pressure (mmHg)"; break;
|
||||||
|
case 10: description = "Systolic Blood Pressure (mmHg)"; break;
|
||||||
|
case 11: description = "Heart Pulse (bpm)"; break;
|
||||||
|
case 54: description = "SP02(%)"; break;
|
||||||
|
}
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS NOTIFICATIONS
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def wNotificationBaseUrl() { "https://wbsapi.withings.net/notify" }
|
||||||
|
|
||||||
|
def wNotificationCallbackUrl() { shortUrl("n") }
|
||||||
|
|
||||||
|
def wGetNotification() {
|
||||||
|
def userId = userid()
|
||||||
|
def url = wNotificationBaseUrl()
|
||||||
|
def params = [
|
||||||
|
action: "subscribe"
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: keep track of notification expiration
|
||||||
|
def wCreateNotification() {
|
||||||
|
def baseUrl = wNotificationBaseUrl()
|
||||||
|
def urlParams = [
|
||||||
|
action : "subscribe",
|
||||||
|
userid : userid(),
|
||||||
|
callbackurl: wNotificationCallbackUrl(),
|
||||||
|
oauth_token: oauth_token(),
|
||||||
|
comment : "hmm" // TODO: figure out what to do here. spaces seem to break the request
|
||||||
|
]
|
||||||
|
|
||||||
|
fetchDataFromWithings(baseUrl, urlParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
def wRevokeAllNotifications() {
|
||||||
|
def notifications = wListNotifications()
|
||||||
|
notifications.each {
|
||||||
|
wRevokeNotification([callbackurl: it.callbackurl]) // use the callbackurl Withings has on file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def wRevokeNotification(notificationParams = [:]) {
|
||||||
|
def baseUrl = wNotificationBaseUrl()
|
||||||
|
def urlParams = [
|
||||||
|
action : "revoke",
|
||||||
|
userid : userid(),
|
||||||
|
callbackurl: wNotificationCallbackUrl(),
|
||||||
|
oauth_token: oauth_token()
|
||||||
|
] + notificationParams
|
||||||
|
|
||||||
|
fetchDataFromWithings(baseUrl, urlParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
def wListNotifications() {
|
||||||
|
|
||||||
|
/*
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
appli: 1,
|
||||||
|
expires: 2147483647,
|
||||||
|
callbackurl: "https://graph.api.smartthings.com/api/t/72ab3e57-5839-4cca-9562-dcc818f83bc9/s/537757a0-c4c8-40ea-8cea-aa283915bbd9/n",
|
||||||
|
comment: "hmm"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
status: 0
|
||||||
|
}*/
|
||||||
|
|
||||||
|
def baseUrl = wNotificationBaseUrl()
|
||||||
|
def urlParams = [
|
||||||
|
action : "list",
|
||||||
|
userid : userid(),
|
||||||
|
callbackurl: wNotificationCallbackUrl(),
|
||||||
|
oauth_token: oauth_token()
|
||||||
|
]
|
||||||
|
|
||||||
|
def notificationData = fetchDataFromWithings(baseUrl, urlParams)
|
||||||
|
notificationData.body.profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
def defaultOauthParams() {
|
||||||
|
defaultParameterKeys().inject([:]) { keyMap, currentKey ->
|
||||||
|
keyMap[currentKey] = "${currentKey}"()
|
||||||
|
keyMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS DATA FETCHING
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def fetchDataFromWithings(baseUrl, urlParams) {
|
||||||
|
|
||||||
|
// log.debug "fetchDataFromWithings(${baseUrl}, ${urlParams})"
|
||||||
|
|
||||||
|
def defaultParams = defaultOauthParams()
|
||||||
|
def paramStrings = buildOauthParams(urlParams + defaultParams)
|
||||||
|
// log.debug "paramStrings: $paramStrings"
|
||||||
|
def url = buildOauthUrl(baseUrl, paramStrings, oauth_token_secret())
|
||||||
|
def json
|
||||||
|
// log.debug "about to make request to ${url}"
|
||||||
|
httpGet(uri: url, headers: ["Content-Type": "application/json"]) { response ->
|
||||||
|
json = new groovy.json.JsonSlurper().parse(response.data)
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS OAUTH LOGGING
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def wLogEnabled() { false } // For troubleshooting Oauth flow
|
||||||
|
|
||||||
|
void wLog(message = "") {
|
||||||
|
if (!wLogEnabled()) { return }
|
||||||
|
def wLogMessage = atomicState.wLogMessage
|
||||||
|
if (wLogMessage.length()) {
|
||||||
|
wLogMessage += "\n|"
|
||||||
|
}
|
||||||
|
wLogMessage += message
|
||||||
|
atomicState.wLogMessage = wLogMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
void wLogNew(seedMessage = "") {
|
||||||
|
if (!wLogEnabled()) { return }
|
||||||
|
def olMessage = atomicState.wLogMessage
|
||||||
|
if (oldMessage) {
|
||||||
|
log.debug "purging old wLogMessage: ${olMessage}"
|
||||||
|
}
|
||||||
|
atomicState.wLogMessage = seedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
String wLogMessage() {
|
||||||
|
if (!wLogEnabled()) { return }
|
||||||
|
def wLogMessage = atomicState.wLogMessage
|
||||||
|
atomicState.wLogMessage = ""
|
||||||
|
wLogMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS OAUTH DESCRIPTION
|
||||||
|
// >>>>>> The user opens the authPage for this SmartApp
|
||||||
|
// STEP 1 get a token to be used in the url the user taps
|
||||||
|
// STEP 2 generate the url to be tapped by the user
|
||||||
|
// >>>>>> The user taps the url and logs in to Withings
|
||||||
|
// STEP 3 generate a token to be used for accessing user data
|
||||||
|
// STEP 4 access user data
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS OAUTH STEP 1: get an oAuth "request token"
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def requestTokenUrl() {
|
||||||
|
wLogNew "WITHINGS OAUTH STEP 1: get an oAuth 'request token'"
|
||||||
|
|
||||||
|
def keys = defaultParameterKeys() + "oauth_callback"
|
||||||
|
def paramStrings = buildOauthParams(keys.sort())
|
||||||
|
|
||||||
|
buildOauthUrl("https://oauth.withings.com/account/request_token", paramStrings, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS OAUTH STEP 2: End-user authorization
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def userAuthorizationUrl() {
|
||||||
|
|
||||||
|
// get url from Step 1
|
||||||
|
def tokenUrl = requestTokenUrl()
|
||||||
|
|
||||||
|
// collect token from Withings
|
||||||
|
collectTokenFromWithings(tokenUrl)
|
||||||
|
|
||||||
|
wLogNew "WITHINGS OAUTH STEP 2: End-user authorization"
|
||||||
|
|
||||||
|
def keys = defaultParameterKeys() + "oauth_token"
|
||||||
|
def paramStrings = buildOauthParams(keys.sort())
|
||||||
|
|
||||||
|
buildOauthUrl("https://oauth.withings.com/account/authorize", paramStrings, oauth_token_secret())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// WITHINGS OAUTH STEP 3: Generating access token
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def exchangeTokenUrl() {
|
||||||
|
wLogNew "WITHINGS OAUTH STEP 3: Generating access token"
|
||||||
|
|
||||||
|
def keys = defaultParameterKeys() + ["oauth_token", "userid"]
|
||||||
|
def paramStrings = buildOauthParams(keys.sort())
|
||||||
|
|
||||||
|
buildOauthUrl("https://oauth.withings.com/account/access_token", paramStrings, oauth_token_secret())
|
||||||
|
}
|
||||||
|
|
||||||
|
def exchangeToken() {
|
||||||
|
|
||||||
|
def tokenUrl = exchangeTokenUrl()
|
||||||
|
// log.debug "about to hit ${tokenUrl}"
|
||||||
|
|
||||||
|
try {
|
||||||
|
// replace old token with a long-lived token
|
||||||
|
def token = collectTokenFromWithings(tokenUrl)
|
||||||
|
// log.debug "collected token from Withings: ${token}"
|
||||||
|
renderAction("authorized", "Withings Connection")
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
log.error e
|
||||||
|
renderAction("notAuthorized", "Withings Connection Failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// OAUTH 1.0
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def defaultParameterKeys() {
|
||||||
|
[
|
||||||
|
"oauth_consumer_key",
|
||||||
|
"oauth_nonce",
|
||||||
|
"oauth_signature_method",
|
||||||
|
"oauth_timestamp",
|
||||||
|
"oauth_version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def oauth_consumer_key() { consumerKey }
|
||||||
|
|
||||||
|
def oauth_nonce() { nonce() }
|
||||||
|
|
||||||
|
def nonce() { UUID.randomUUID().toString().replaceAll("-", "") }
|
||||||
|
|
||||||
|
def oauth_signature_method() { "HMAC-SHA1" }
|
||||||
|
|
||||||
|
def oauth_timestamp() { (int) (new Date().time / 1000) }
|
||||||
|
|
||||||
|
def oauth_version() { 1.0 }
|
||||||
|
|
||||||
|
def oauth_callback() { shortUrl("x") }
|
||||||
|
|
||||||
|
def oauth_token() { atomicState.wToken?.oauth_token }
|
||||||
|
|
||||||
|
def oauth_token_secret() { atomicState.wToken?.oauth_token_secret }
|
||||||
|
|
||||||
|
def userid() { atomicState.userid }
|
||||||
|
|
||||||
|
String hmac(String oAuthSignatureBaseString, String oAuthSecret) throws java.security.SignatureException {
|
||||||
|
if (!oAuthSecret.contains("&")) { log.warn "Withings requires \"&\" to be included no matter what" }
|
||||||
|
// get an hmac_sha1 key from the raw key bytes
|
||||||
|
def signingKey = new javax.crypto.spec.SecretKeySpec(oAuthSecret.getBytes(), "HmacSHA1")
|
||||||
|
// get an hmac_sha1 Mac instance and initialize with the signing key
|
||||||
|
def mac = javax.crypto.Mac.getInstance("HmacSHA1")
|
||||||
|
mac.init(signingKey)
|
||||||
|
// compute the hmac on input data bytes
|
||||||
|
byte[] rawHmac = mac.doFinal(oAuthSignatureBaseString.getBytes())
|
||||||
|
return org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac)
|
||||||
|
}
|
||||||
|
|
||||||
|
Map parseResponseString(String responseString) {
|
||||||
|
// log.debug "parseResponseString: ${responseString}"
|
||||||
|
responseString.split("&").inject([:]) { c, it ->
|
||||||
|
def parts = it.split('=')
|
||||||
|
def k = parts[0]
|
||||||
|
def v = parts[1]
|
||||||
|
c[k] = v
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String applyParams(endpoint, oauthParams) { endpoint + "?" + oauthParams.sort().join("&") }
|
||||||
|
|
||||||
|
String buildSignature(endpoint, oAuthParams, oAuthSecret) {
|
||||||
|
def oAuthSignatureBaseParts = ["GET", endpoint, oAuthParams.join("&")]
|
||||||
|
def oAuthSignatureBaseString = oAuthSignatureBaseParts.collect { URLEncoder.encode(it) }.join("&")
|
||||||
|
wLog " ==> oAuth signature base string : \n${oAuthSignatureBaseString}"
|
||||||
|
wLog " .. applying hmac-sha1 to base string, with secret : ${oAuthSecret} (notice the \"&\")"
|
||||||
|
wLog " .. base64 encode then url-encode the hmac-sha1 hash"
|
||||||
|
String hmacResult = hmac(oAuthSignatureBaseString, oAuthSecret)
|
||||||
|
def signature = URLEncoder.encode(hmacResult)
|
||||||
|
wLog " ==> oauth_signature = ${signature}"
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
|
List buildOauthParams(List parameterKeys) {
|
||||||
|
wLog " .. adding oAuth parameters : "
|
||||||
|
def oauthParams = []
|
||||||
|
parameterKeys.each { key ->
|
||||||
|
def value = "${key}"()
|
||||||
|
wLog " ${key} = ${value}"
|
||||||
|
oauthParams << "${key}=${URLEncoder.encode(value.toString())}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wLog " .. sorting all request parameters alphabetically "
|
||||||
|
oauthParams.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
List buildOauthParams(Map parameters) {
|
||||||
|
wLog " .. adding oAuth parameters : "
|
||||||
|
def oauthParams = []
|
||||||
|
parameters.each { k, v ->
|
||||||
|
wLog " ${k} = ${v}"
|
||||||
|
oauthParams << "${k}=${URLEncoder.encode(v.toString())}"
|
||||||
|
}
|
||||||
|
|
||||||
|
wLog " .. sorting all request parameters alphabetically "
|
||||||
|
oauthParams.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
String buildOauthUrl(String endpoint, List parameterStrings, String oAuthTokenSecret) {
|
||||||
|
wLog "Api endpoint : ${endpoint}"
|
||||||
|
|
||||||
|
wLog "Signing request :"
|
||||||
|
def oAuthSecret = "${consumerSecret}&${oAuthTokenSecret}"
|
||||||
|
def signature = buildSignature(endpoint, parameterStrings, oAuthSecret)
|
||||||
|
|
||||||
|
parameterStrings << "oauth_signature=${signature}"
|
||||||
|
|
||||||
|
def finalUrl = applyParams(endpoint, parameterStrings)
|
||||||
|
wLog "Result: ${finalUrl}"
|
||||||
|
if (wLogEnabled()) {
|
||||||
|
log.debug wLogMessage()
|
||||||
|
}
|
||||||
|
return finalUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
def collectTokenFromWithings(tokenUrl) {
|
||||||
|
// get token from Withings using the url generated in Step 1
|
||||||
|
def tokenString
|
||||||
|
httpGet(uri: tokenUrl) { resp -> // oauth_token=<token_key>&oauth_token_secret=<token_secret>
|
||||||
|
tokenString = resp.data.toString()
|
||||||
|
// log.debug "collectTokenFromWithings: ${tokenString}"
|
||||||
|
}
|
||||||
|
def token = parseResponseString(tokenString)
|
||||||
|
atomicState.wToken = token
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// APP SETTINGS
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def getConsumerKey() { appSettings.consumerKey }
|
||||||
|
|
||||||
|
def getConsumerSecret() { appSettings.consumerSecret }
|
||||||
|
|
||||||
|
// figure out how to put this in settings
|
||||||
|
def getUserId() { atomicState.wToken?.userid }
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// HTML rendering
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
def renderAction(action, title = "") {
|
||||||
|
log.debug "renderAction: $action"
|
||||||
|
renderHTML(title) {
|
||||||
|
head { "${action}HtmlHead"() }
|
||||||
|
body { "${action}HtmlBody"() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def authorizedHtmlHead() {
|
||||||
|
log.trace "authorizedHtmlHead"
|
||||||
|
"""
|
||||||
|
<style type="text/css">
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Swiss 721 W01 Thin';
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Swiss 721 W01 Light';
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
|
||||||
|
src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
|
||||||
|
url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
/*width: 560px;
|
||||||
|
padding: 40px;*/
|
||||||
|
/*background: #eee;*/
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
vertical-align: middle;
|
||||||
|
max-width:20%;
|
||||||
|
}
|
||||||
|
img:nth-child(2) {
|
||||||
|
margin: 0 30px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
/*font-size: 1.2em;*/
|
||||||
|
font-family: 'Swiss 721 W01 Thin';
|
||||||
|
text-align: center;
|
||||||
|
color: #666666;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
p:last-child {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
span {
|
||||||
|
font-family: 'Swiss 721 W01 Light';
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
def authorizedHtmlBody() {
|
||||||
|
"""
|
||||||
|
<div class="container">
|
||||||
|
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/withings@2x.png" alt="withings icon" />
|
||||||
|
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
|
||||||
|
<img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
|
||||||
|
<p>Your Withings scale is now connected to SmartThings!</p>
|
||||||
|
<p>Click 'Done' to finish setup.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
def notAuthorizedHtmlHead() {
|
||||||
|
log.trace "notAuthorizedHtmlHead"
|
||||||
|
authorizedHtmlHead()
|
||||||
|
}
|
||||||
|
|
||||||
|
def notAuthorizedHtmlBody() {
|
||||||
|
"""
|
||||||
|
<div class="container">
|
||||||
|
<p>There was an error connecting to SmartThings!</p>
|
||||||
|
<p>Click 'Done' to try again.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user