Update influxdb-logger.groovy
[smartapps.git] / official / hue-connect.groovy
1 /**
2  *  Hue Service Manager
3  *
4  *  Author: Juan Risso (juan@smartthings.com)
5  *
6  *  Copyright 2015 SmartThings
7  *
8  *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
9  *  in compliance with the License. You may obtain a copy of the License at:
10  *
11  *  http://www.apache.org/licenses/LICENSE-2.0
12  *
13  *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
14  *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
15  *  for the specific language governing permissions and limitations under the License.
16  *
17  */
18 include 'localization'
19
20 definition(
21                 name: "Hue (Connect)",
22                 namespace: "smartthings",
23                 author: "SmartThings",
24                 description: "Allows you to connect your Philips Hue lights with SmartThings and control them from your Things area or Dashboard in the SmartThings Mobile app. Please update your Hue Bridge first, outside of the SmartThings app, using the Philips Hue app.",
25                 category: "SmartThings Labs",
26                 iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/hue.png",
27                 iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/hue@2x.png",
28                 singleInstance: true
29 )
30
31 preferences {
32         page(name: "mainPage", title: "", content: "mainPage", refreshTimeout: 5)
33         page(name: "bridgeDiscovery", title: "", content: "bridgeDiscovery", refreshTimeout: 5)
34         page(name: "bridgeDiscoveryFailed", title: "", content: "bridgeDiscoveryFailed", refreshTimeout: 0)
35         page(name: "bridgeBtnPush", title: "", content: "bridgeLinking", refreshTimeout: 5)
36         page(name: "bulbDiscovery", title: "", content: "bulbDiscovery", refreshTimeout: 5)
37 }
38
39 def mainPage() {
40         def bridges = bridgesDiscovered()
41
42         if (state.refreshUsernameNeeded) {
43                 return bridgeLinking()
44         } else if (state.username && bridges) {
45                 return bulbDiscovery()
46         } else {
47                 return bridgeDiscovery()
48         }
49 }
50
51 def bridgeDiscovery(params = [:]) {
52         def bridges = bridgesDiscovered()
53         int bridgeRefreshCount = !state.bridgeRefreshCount ? 0 : state.bridgeRefreshCount as int
54         state.bridgeRefreshCount = bridgeRefreshCount + 1
55         def refreshInterval = 3
56
57         def options = bridges ?: []
58         def numFound = options.size() ?: "0"
59         if (numFound == 0) {
60                 if (state.bridgeRefreshCount == 25) {
61                         log.trace "Cleaning old bridges memory"
62                         state.bridges = [:]
63                         app.updateSetting("selectedHue", "")
64                 } else if (state.bridgeRefreshCount > 100) {
65                         // five minutes have passed, give up
66                         // there seems to be a problem going back from discovey failed page in some instances (compared to pressing next)
67                         // however it is probably a SmartThings settings issue
68                         state.bridges = [:]
69                         app.updateSetting("selectedHue", "")
70                         state.bridgeRefreshCount = 0
71                         return bridgeDiscoveryFailed()
72                 }
73         }
74
75         ssdpSubscribe()
76         log.trace "bridgeRefreshCount: $bridgeRefreshCount"
77         //bridge discovery request every 15 //25 seconds
78         if ((bridgeRefreshCount % 5) == 0) {
79                 discoverBridges()
80         }
81
82         //setup.xml request every 3 seconds except on discoveries
83         if (((bridgeRefreshCount % 3) == 0) && ((bridgeRefreshCount % 5) != 0)) {
84                 verifyHueBridges()
85         }
86
87         return dynamicPage(name: "bridgeDiscovery", title: "Discovery Started!", nextPage: "bridgeBtnPush", refreshInterval: refreshInterval, uninstall: true) {
88                 section("Please wait while we discover your Hue Bridge. Kindly note that you must first configure your Hue Bridge and Lights using the Philips Hue application. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") {
89
90
91                         input(name: "selectedHue", type: "enum", required: false, title: "Select Hue Bridge ({{numFound}} found)", messageArgs: [numFound: numFound], multiple: false, options: options, submitOnChange: true)
92                 }
93         }
94 }
95
96 def bridgeDiscoveryFailed() {
97         return dynamicPage(name: "bridgeDiscoveryFailed", title: "Bridge Discovery Failed!", nextPage: "bridgeDiscovery") {
98                 section("Failed to discover any Hue Bridges. Please confirm that the Hue Bridge is connected to the same network as your SmartThings Hub, and that it has power.") {
99                 }
100         }
101 }
102
103 def bridgeLinking() {
104         int linkRefreshcount = !state.linkRefreshcount ? 0 : state.linkRefreshcount as int
105         state.linkRefreshcount = linkRefreshcount + 1
106         def refreshInterval = 3
107
108         def nextPage = ""
109         def title = "Linking with your Hue"
110         def paragraphText
111         if (selectedHue) {
112                 if (state.refreshUsernameNeeded) {
113                         paragraphText = "The current Hue username is invalid. Please press the button on your Hue Bridge to relink."
114                 } else {
115                         paragraphText = "Press the button on your Hue Bridge to setup a link."
116                 }
117         } else {
118                 paragraphText = "You haven't selected a Hue Bridge, please Press 'Done' and select one before clicking next."
119         }
120         if (state.username) { //if discovery worked
121                 if (state.refreshUsernameNeeded) {
122                         state.refreshUsernameNeeded = false
123                         // Issue one poll with new username to cancel local polling with old username
124                         poll()
125                 }
126                 nextPage = "bulbDiscovery"
127                 title = "Success!"
128                 paragraphText = "Linking to your hub was a success! Please click 'Next'!"
129         }
130
131         if ((linkRefreshcount % 2) == 0 && !state.username) {
132                 sendDeveloperReq()
133         }
134
135         return dynamicPage(name: "bridgeBtnPush", title: title, nextPage: nextPage, refreshInterval: refreshInterval) {
136                 section("") {
137                         paragraph "$paragraphText"
138                 }
139         }
140 }
141
142 def bulbDiscovery() {
143         int bulbRefreshCount = !state.bulbRefreshCount ? 0 : state.bulbRefreshCount as int
144         state.bulbRefreshCount = bulbRefreshCount + 1
145         def refreshInterval = 3
146         state.inBulbDiscovery = true
147         def bridge = null
148
149         state.bridgeRefreshCount = 0
150         def allLightsFound = bulbsDiscovered() ?: [:]
151
152         // List lights currently not added to the user (editable)
153         def newLights = allLightsFound.findAll { getChildDevice(it.key) == null } ?: [:]
154         newLights = newLights.sort { it.value.toLowerCase() }
155
156         // List lights already added to the user (not editable)
157         def existingLights = allLightsFound.findAll { getChildDevice(it.key) != null } ?: [:]
158         existingLights = existingLights.sort { it.value.toLowerCase() }
159
160         def numFound = newLights.size() ?: "0"
161         if (numFound == "0")
162                 app.updateSetting("selectedBulbs", "")
163
164         if ((bulbRefreshCount % 5) == 0) {
165                 discoverHueBulbs()
166         }
167         def selectedBridge = state.bridges.find { key, value -> value?.serialNumber?.equalsIgnoreCase(selectedHue) }
168         def title = selectedBridge?.value?.name ?: "Find bridges"
169
170         // List of all lights previously added shown to user
171         def existingLightsDescription = ""
172         if (existingLights) {
173                 existingLights.each {
174                         if (existingLightsDescription.isEmpty()) {
175                                 existingLightsDescription += it.value
176                         } else {
177                                 existingLightsDescription += ", ${it.value}"
178                         }
179                 }
180         }
181
182         def existingLightsSize = "${existingLights.size()}"
183         if (bulbRefreshCount > 200 && numFound == "0") {
184                 // Time out after 10 minutes
185                 state.inBulbDiscovery = false
186                 bulbRefreshCount = 0
187                 return dynamicPage(name: "bulbDiscovery", title: "Light Discovery Failed!", nextPage: "", refreshInterval: 0, install: true, uninstall: true) {
188                         section("Failed to discover any lights, please try again later. Click 'Done' to exit.") {
189                                 paragraph title: "Previously added Hue Lights ({{existingLightsSize}} added)", messageArgs: [existingLightsSize: existingLightsSize], existingLightsDescription
190                         }
191                         section {
192                                 href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true]
193                         }
194                 }
195
196         } else {
197                 return dynamicPage(name: "bulbDiscovery", title: "Light Discovery Started!", nextPage: "", refreshInterval: refreshInterval, install: true, uninstall: true) {
198                         section("Please wait while we discover your Hue Lights. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") {
199                                 input(name: "selectedBulbs", type: "enum", required: false, title: "Select Hue Lights to add ({{numFound}} found)", messageArgs: [numFound: numFound], multiple: true, submitOnChange: true, options: newLights)
200                                 paragraph title: "Previously added Hue Lights ({{existingLightsSize}} added)", messageArgs: [existingLightsSize: existingLightsSize], existingLightsDescription
201                         }
202                         section {
203                                 href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true]
204                         }
205                 }
206         }
207 }
208
209 private discoverBridges() {
210         log.trace "Sending Hue Discovery message to the hub"
211         sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:basic:1", physicalgraph.device.Protocol.LAN))
212 }
213
214 void ssdpSubscribe() {
215         subscribe(location, "ssdpTerm.urn:schemas-upnp-org:device:basic:1", ssdpBridgeHandler)
216 }
217
218 private sendDeveloperReq() {
219         def token = app.id
220         def host = getBridgeIP()
221         sendHubCommand(new physicalgraph.device.HubAction([
222                         method : "POST",
223                         path   : "/api",
224                         headers: [
225                                         HOST: host
226                         ],
227                         body   : [devicetype: "$token-0"]], "${selectedHue}", [callback: "usernameHandler"]))
228 }
229
230 private discoverHueBulbs() {
231         def host = getBridgeIP()
232         sendHubCommand(new physicalgraph.device.HubAction([
233                         method : "GET",
234                         path   : "/api/${state.username}/lights",
235                         headers: [
236                                         HOST: host
237                         ]], "${selectedHue}", [callback: "lightsHandler"]))
238 }
239
240 private verifyHueBridge(String deviceNetworkId, String host) {
241         log.trace "Verify Hue Bridge $deviceNetworkId"
242         sendHubCommand(new physicalgraph.device.HubAction([
243                         method : "GET",
244                         path   : "/description.xml",
245                         headers: [
246                                         HOST: host
247                         ]], deviceNetworkId, [callback: "bridgeDescriptionHandler"]))
248 }
249
250 private verifyHueBridges() {
251         def devices = getHueBridges().findAll { it?.value?.verified != true }
252         devices.each {
253                 def ip = convertHexToIP(it.value.networkAddress)
254                 def port = convertHexToInt(it.value.deviceAddress)
255                 verifyHueBridge("${it.value.mac}", (ip + ":" + port))
256         }
257 }
258
259 Map bridgesDiscovered() {
260         def vbridges = getVerifiedHueBridges()
261         def map = [:]
262         vbridges.each {
263                 def value = "${it.value.name}"
264                 def key = "${it.value.mac}"
265                 map["${key}"] = value
266         }
267         map
268 }
269
270 Map bulbsDiscovered() {
271         def bulbs = getHueBulbs()
272         def bulbmap = [:]
273         if (bulbs instanceof java.util.Map) {
274                 bulbs.each {
275                         def value = "${it.value.name}"
276                         def key = app.id + "/" + it.value.id
277                         bulbmap["${key}"] = value
278                 }
279         } else { //backwards compatable
280                 bulbs.each {
281                         def value = "${it.name}"
282                         def key = app.id + "/" + it.id
283                         logg += "$value - $key, "
284                         bulbmap["${key}"] = value
285                 }
286         }
287         return bulbmap
288 }
289
290 Map getHueBulbs() {
291         state.bulbs = state.bulbs ?: [:]
292 }
293
294 def getHueBridges() {
295         state.bridges = state.bridges ?: [:]
296 }
297
298 def getVerifiedHueBridges() {
299         getHueBridges().findAll { it?.value?.verified == true }
300 }
301
302 def installed() {
303         log.trace "Installed with settings: ${settings}"
304         initialize()
305 }
306
307 def updated() {
308         log.trace "Updated with settings: ${settings}"
309         unsubscribe()
310         unschedule()
311         initialize()
312 }
313
314 def initialize() {
315         log.debug "Initializing"
316         unsubscribe(bridge)
317         state.inBulbDiscovery = false
318         state.bridgeRefreshCount = 0
319         state.bulbRefreshCount = 0
320         state.updating = false
321         setupDeviceWatch()
322         if (selectedHue) {
323                 addBridge()
324                 addBulbs()
325                 doDeviceSync()
326                 runEvery5Minutes("doDeviceSync")
327         }
328 }
329
330 def manualRefresh() {
331         checkBridgeStatus()
332         state.updating = false
333         poll()
334 }
335
336 def uninstalled() {
337         // Remove bridgedevice connection to allow uninstall of smartapp even though bridge is listed
338         // as user of smartapp
339         app.updateSetting("bridgeDevice", null)
340         state.bridges = [:]
341         state.username = null
342 }
343
344 private setupDeviceWatch() {
345         def hub = location.hubs[0]
346         // Make sure that all child devices are enrolled in device watch
347         getChildDevices().each {
348                 it.sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${hub?.hub?.hardwareID}\"}")
349         }
350 }
351
352 private upgradeDeviceType(device, newHueType) {
353         def deviceType = getDeviceType(newHueType)
354
355         // Automatically change users Hue bulbs to correct device types
356         if (deviceType && !(device?.typeName?.equalsIgnoreCase(deviceType))) {
357                 log.debug "Update device type: \"$device.label\" ${device?.typeName}->$deviceType"
358                 device.setDeviceType(deviceType)
359         }
360 }
361
362 private getDeviceType(hueType) {
363         // Determine ST device type based on Hue classification of light
364         if (hueType?.equalsIgnoreCase("Dimmable light"))
365                 return "Hue Lux Bulb"
366         else if (hueType?.equalsIgnoreCase("Extended Color Light"))
367                 return "Hue Bulb"
368         else if (hueType?.equalsIgnoreCase("Color Light"))
369                 return "Hue Bloom"
370         else if (hueType?.equalsIgnoreCase("Color Temperature Light"))
371                 return "Hue White Ambiance Bulb"
372         else
373                 return null
374 }
375
376 private addChildBulb(dni, hueType, name, hub, update = false, device = null) {
377         def deviceType = getDeviceType(hueType)
378
379         if (deviceType) {
380                 return addChildDevice("smartthings", deviceType, dni, hub, ["label": name])
381         } else {
382                 log.warn "Device type $hueType not supported"
383                 return null
384         }
385 }
386
387 def addBulbs() {
388         def bulbs = getHueBulbs()
389         selectedBulbs?.each { dni ->
390                 def d = getChildDevice(dni)
391                 if (!d) {
392                         def newHueBulb
393                         if (bulbs instanceof java.util.Map) {
394                                 newHueBulb = bulbs.find { (app.id + "/" + it.value.id) == dni }
395                                 if (newHueBulb != null) {
396                                         d = addChildBulb(dni, newHueBulb?.value?.type, newHueBulb?.value?.name, newHueBulb?.value?.hub)
397                                         if (d) {
398                                                 log.debug "created ${d.displayName} with id $dni"
399                                                 d.completedSetup = true
400                                         }
401                                 } else {
402                                         log.debug "$dni in not longer paired to the Hue Bridge or ID changed"
403                                 }
404                         } else {
405                                 //backwards compatable
406                                 newHueBulb = bulbs.find { (app.id + "/" + it.id) == dni }
407                                 d = addChildBulb(dni, "Extended Color Light", newHueBulb?.value?.name, newHueBulb?.value?.hub)
408                                 d?.completedSetup = true
409                                 d?.refresh()
410                         }
411                 } else {
412                         log.debug "found ${d.displayName} with id $dni already exists, type: '$d.typeName'"
413                         if (bulbs instanceof java.util.Map) {
414                                 // Update device type if incorrect
415                                 def newHueBulb = bulbs.find { (app.id + "/" + it.value.id) == dni }
416                                 upgradeDeviceType(d, newHueBulb?.value?.type)
417                         }
418                 }
419         }
420 }
421
422 def addBridge() {
423         def vbridges = getVerifiedHueBridges()
424         def vbridge = vbridges.find { "${it.value.mac}" == selectedHue }
425
426         if (vbridge) {
427                 def d = getChildDevice(selectedHue)
428                 if (!d) {
429                         // compatibility with old devices
430                         def newbridge = true
431                         childDevices.each {
432                                 if (it.getDeviceDataByName("mac")) {
433                                         def newDNI = "${it.getDeviceDataByName("mac")}"
434                                         if (newDNI != it.deviceNetworkId) {
435                                                 def oldDNI = it.deviceNetworkId
436                                                 log.debug "updating dni for device ${it} with $newDNI - previous DNI = ${it.deviceNetworkId}"
437                                                 it.setDeviceNetworkId("${newDNI}")
438                                                 if (oldDNI == selectedHue) {
439                                                         app.updateSetting("selectedHue", newDNI)
440                                                 }
441                                                 newbridge = false
442                                         }
443                                 }
444                         }
445                         if (newbridge) {
446                                 // Hue uses last 6 digits of MAC address as ID number, this number is shown on the bottom of the bridge
447                                 def idNumber = getBridgeIdNumber(selectedHue)
448                                 d = addChildDevice("smartthings", "Hue Bridge", selectedHue, vbridge.value.hub, ["label": "Hue Bridge ($idNumber)"])
449                                 if (d) {
450                                         // Associate smartapp to bridge so user will be warned if trying to delete bridge
451                                         app.updateSetting("bridgeDevice", [type: "device.hueBridge", value: d.id])
452
453                                         d.completedSetup = true
454                                         log.debug "created ${d.displayName} with id ${d.deviceNetworkId}"
455                                         def childDevice = getChildDevice(d.deviceNetworkId)
456                                         childDevice?.sendEvent(name: "status", value: "Online")
457                                         childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true)
458                                         updateBridgeStatus(childDevice)
459
460                                         childDevice?.sendEvent(name: "idNumber", value: idNumber)
461                                         if (vbridge.value.ip && vbridge.value.port) {
462                                                 if (vbridge.value.ip.contains(".")) {
463                                                         childDevice.sendEvent(name: "networkAddress", value: vbridge.value.ip + ":" + vbridge.value.port)
464                                                         childDevice.updateDataValue("networkAddress", vbridge.value.ip + ":" + vbridge.value.port)
465                                                 } else {
466                                                         childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port))
467                                                         childDevice.updateDataValue("networkAddress", convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port))
468                                                 }
469                                         } else {
470                                                 childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.networkAddress) + ":" + convertHexToInt(vbridge.value.deviceAddress))
471                                                 childDevice.updateDataValue("networkAddress", convertHexToIP(vbridge.value.networkAddress) + ":" + convertHexToInt(vbridge.value.deviceAddress))
472                                         }
473                                 } else {
474                                         log.error "Failed to create Hue Bridge device"
475                                 }
476                         }
477                 } else {
478                         log.debug "found ${d.displayName} with id $selectedHue already exists"
479                 }
480         }
481 }
482
483 def ssdpBridgeHandler(evt) {
484         def description = evt.description
485         log.trace "Location: $description"
486
487         def hub = evt?.hubId
488         def parsedEvent = parseLanMessage(description)
489         parsedEvent << ["hub": hub]
490
491         def bridges = getHueBridges()
492         log.trace bridges.toString()
493         if (!(bridges."${parsedEvent.ssdpUSN.toString()}")) {
494                 //bridge does not exist
495                 log.trace "Adding bridge ${parsedEvent.ssdpUSN}"
496                 bridges << ["${parsedEvent.ssdpUSN.toString()}": parsedEvent]
497         } else {
498                 // update the values
499                 def ip = convertHexToIP(parsedEvent.networkAddress)
500                 def port = convertHexToInt(parsedEvent.deviceAddress)
501                 def host = ip + ":" + port
502                 log.debug "Device ($parsedEvent.mac) was already found in state with ip = $host."
503                 def dstate = bridges."${parsedEvent.ssdpUSN.toString()}"
504                 def dniReceived = "${parsedEvent.mac}"
505                 def currentDni = dstate.mac
506                 def d = getChildDevice(dniReceived)
507                 def networkAddress = null
508                 if (!d) {
509                         // There might be a mismatch between bridge DNI and the actual bridge mac address, correct that
510                         log.debug "Bridge with $dniReceived not found"
511                         def bridge = childDevices.find { it.deviceNetworkId == currentDni }
512                         if (bridge != null) {
513                                 log.warn "Bridge is set to ${bridge.deviceNetworkId}, updating to $dniReceived"
514                                 bridge.setDeviceNetworkId("${dniReceived}")
515                                 dstate.mac = dniReceived
516                                 // Check to see if selectedHue is a valid bridge, otherwise update it
517                                 def isSelectedValid = bridges?.find { it.value?.mac == selectedHue }
518                                 if (isSelectedValid == null) {
519                                         log.warn "Correcting selectedHue in state"
520                                         app.updateSetting("selectedHue", dniReceived)
521                                 }
522                                 doDeviceSync()
523                         }
524                 } else {
525                         updateBridgeStatus(d)
526                         if (d.getDeviceDataByName("networkAddress")) {
527                                 networkAddress = d.getDeviceDataByName("networkAddress")
528                         } else {
529                                 networkAddress = d.latestState('networkAddress').stringValue
530                         }
531                         log.trace "Host: $host - $networkAddress"
532                         if (host != networkAddress) {
533                                 log.debug "Device's port or ip changed for device $d..."
534                                 dstate.ip = ip
535                                 dstate.port = port
536                                 dstate.name = "Philips hue ($ip)"
537                                 d.sendEvent(name: "networkAddress", value: host)
538                                 d.updateDataValue("networkAddress", host)
539                         }
540                         if (dstate.mac != dniReceived) {
541                                 log.warn "Correcting bridge mac address in state"
542                                 dstate.mac = dniReceived
543                         }
544                         if (selectedHue != dniReceived) {
545                                 // Check to see if selectedHue is a valid bridge, otherwise update it
546                                 def isSelectedValid = bridges?.find { it.value?.mac == selectedHue }
547                                 if (isSelectedValid == null) {
548                                         log.warn "Correcting selectedHue in state"
549                                         app.updateSetting("selectedHue", dniReceived)
550                                 }
551                         }
552                 }
553         }
554 }
555
556 void bridgeDescriptionHandler(physicalgraph.device.HubResponse hubResponse) {
557         log.trace "description.xml response (application/xml)"
558         def body = hubResponse.xml
559         if (body?.device?.modelName?.text()?.startsWith("Philips hue bridge")) {
560                 def bridges = getHueBridges()
561                 def bridge = bridges.find { it?.key?.contains(body?.device?.UDN?.text()) }
562                 if (bridge) {
563                         def idNumber = getBridgeIdNumber(body?.device?.serialNumber?.text())
564
565                         // usually in form of bridge name followed by (ip), i.e. defaults to Philips Hue (192.168.1.2)
566                         // replace IP with id number to make it easier for user to identify
567                         def name = body?.device?.friendlyName?.text()
568                         def index = name?.indexOf('(')
569                         if (index != -1) {
570                                 name = name.substring(0, index)
571                                 name += " ($idNumber)"
572                         }
573                         bridge.value << [name: name, serialNumber: body?.device?.serialNumber?.text(), idNumber: idNumber, verified: true]
574                 } else {
575                         log.error "/description.xml returned a bridge that didn't exist"
576                 }
577         }
578 }
579
580 void lightsHandler(physicalgraph.device.HubResponse hubResponse) {
581         if (isValidSource(hubResponse.mac)) {
582                 def body = hubResponse.json
583                 if (!body?.state?.on) { //check if first time poll made it here by mistake
584                         log.debug "Adding bulbs to state!"
585                         updateBulbState(body, hubResponse.hubId)
586                 }
587         }
588 }
589
590 void usernameHandler(physicalgraph.device.HubResponse hubResponse) {
591         if (isValidSource(hubResponse.mac)) {
592                 def body = hubResponse.json
593                 if (body.success != null) {
594                         if (body.success[0] != null) {
595                                 if (body.success[0].username)
596                                         state.username = body.success[0].username
597                         }
598                 } else if (body.error != null) {
599                         //TODO: handle retries...
600                         log.error "ERROR: application/json ${body.error}"
601                 }
602         }
603 }
604
605 /**
606  * @deprecated This has been replaced by the combination of {@link #ssdpBridgeHandler()}, {@link #bridgeDescriptionHandler()},
607  * {@link #lightsHandler()}, and {@link #usernameHandler()}. After a pending event subscription migration, it can be removed.
608  */
609 @Deprecated
610 def locationHandler(evt) {
611         def description = evt.description
612         log.trace "Location: $description"
613
614         def hub = evt?.hubId
615         def parsedEvent = parseLanMessage(description)
616         parsedEvent << ["hub": hub]
617
618         if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:basic:1")) {
619                 //SSDP DISCOVERY EVENTS
620                 log.trace "SSDP DISCOVERY EVENTS"
621                 def bridges = getHueBridges()
622                 log.trace bridges.toString()
623                 if (!(bridges."${parsedEvent.ssdpUSN.toString()}")) {
624                         //bridge does not exist
625                         log.trace "Adding bridge ${parsedEvent.ssdpUSN}"
626                         bridges << ["${parsedEvent.ssdpUSN.toString()}": parsedEvent]
627                 } else {
628                         // update the values
629                         def ip = convertHexToIP(parsedEvent.networkAddress)
630                         def port = convertHexToInt(parsedEvent.deviceAddress)
631                         def host = ip + ":" + port
632                         log.debug "Device ($parsedEvent.mac) was already found in state with ip = $host."
633                         def dstate = bridges."${parsedEvent.ssdpUSN.toString()}"
634                         def dni = "${parsedEvent.mac}"
635                         def d = getChildDevice(dni)
636                         def networkAddress = null
637                         if (!d) {
638                                 childDevices.each {
639                                         if (it.getDeviceDataByName("mac")) {
640                                                 def newDNI = "${it.getDeviceDataByName("mac")}"
641                                                 d = it
642                                                 if (newDNI != it.deviceNetworkId) {
643                                                         def oldDNI = it.deviceNetworkId
644                                                         log.debug "updating dni for device ${it} with $newDNI - previous DNI = ${it.deviceNetworkId}"
645                                                         it.setDeviceNetworkId("${newDNI}")
646                                                         if (oldDNI == selectedHue) {
647                                                                 app.updateSetting("selectedHue", newDNI)
648                                                         }
649                                                         doDeviceSync()
650                                                 }
651                                         }
652                                 }
653                         } else {
654                                 updateBridgeStatus(d)
655                                 if (d.getDeviceDataByName("networkAddress")) {
656                                         networkAddress = d.getDeviceDataByName("networkAddress")
657                                 } else {
658                                         networkAddress = d.latestState('networkAddress').stringValue
659                                 }
660                                 log.trace "Host: $host - $networkAddress"
661                                 if (host != networkAddress) {
662                                         log.debug "Device's port or ip changed for device $d..."
663                                         dstate.ip = ip
664                                         dstate.port = port
665                                         dstate.name = "Philips hue ($ip)"
666                                         d.sendEvent(name: "networkAddress", value: host)
667                                         d.updateDataValue("networkAddress", host)
668                                 }
669                         }
670                 }
671         } else if (parsedEvent.headers && parsedEvent.body) {
672                 log.trace "HUE BRIDGE RESPONSES"
673                 def headerString = parsedEvent.headers.toString()
674                 if (headerString?.contains("xml")) {
675                         log.trace "description.xml response (application/xml)"
676                         def body = new XmlSlurper().parseText(parsedEvent.body)
677                         if (body?.device?.modelName?.text().startsWith("Philips hue bridge")) {
678                                 def bridges = getHueBridges()
679                                 def bridge = bridges.find { it?.key?.contains(body?.device?.UDN?.text()) }
680                                 if (bridge) {
681                                         bridge.value << [name: body?.device?.friendlyName?.text(), serialNumber: body?.device?.serialNumber?.text(), verified: true]
682                                 } else {
683                                         log.error "/description.xml returned a bridge that didn't exist"
684                                 }
685                         }
686                 } else if (headerString?.contains("json") && isValidSource(parsedEvent.mac)) {
687                         log.trace "description.xml response (application/json)"
688                         def body = new groovy.json.JsonSlurper().parseText(parsedEvent.body)
689                         if (body.success != null) {
690                                 if (body.success[0] != null) {
691                                         if (body.success[0].username) {
692                                                 state.username = body.success[0].username
693                                         }
694                                 }
695                         } else if (body.error != null) {
696                                 //TODO: handle retries...
697                                 log.error "ERROR: application/json ${body.error}"
698                         } else {
699                                 //GET /api/${state.username}/lights response (application/json)
700                                 if (!body?.state?.on) { //check if first time poll made it here by mistake
701                                         log.debug "Adding bulbs to state!"
702                                         updateBulbState(body, parsedEvent.hub)
703                                 }
704                         }
705                 }
706         } else {
707                 log.trace "NON-HUE EVENT $evt.description"
708         }
709 }
710
711 def doDeviceSync() {
712         log.trace "Doing Hue Device Sync!"
713
714         // Check if state.updating failed to clear
715         if (state.lastUpdateStarted < (now() - 20 * 1000) && state.updating) {
716                 state.updating = false
717                 log.warn "state.updating failed to clear"
718         }
719
720         convertBulbListToMap()
721         poll()
722         ssdpSubscribe()
723         discoverBridges()
724         checkBridgeStatus()
725 }
726
727 /**
728  * Called when data is received from the Hue bridge, this will update the lastActivity() that
729  * is used to keep track of online/offline status of the bridge. Bridge is considered offline
730  * if not heard from in 16 minutes
731  *
732  * @param childDevice Hue Bridge child device
733  */
734 private void updateBridgeStatus(childDevice) {
735         // Update activity timestamp if child device is a valid bridge
736         def vbridges = getVerifiedHueBridges()
737         def vbridge = vbridges.find {
738                 "${it.value.mac}".toUpperCase() == childDevice?.device?.deviceNetworkId?.toUpperCase()
739         }
740         vbridge?.value?.lastActivity = now()
741         if (vbridge && childDevice?.device?.currentValue("status") == "Offline") {
742                 log.debug "$childDevice is back Online"
743                 childDevice?.sendEvent(name: "status", value: "Online")
744                 childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true)
745         }
746 }
747
748 /**
749  * Check if all Hue bridges have been heard from in the last 11 minutes, if not an Offline event will be sent
750  * for the bridge and all connected lights. Also, set ID number on bridge if not done previously.
751  */
752 private void checkBridgeStatus() {
753         def bridges = getHueBridges()
754         // Check if each bridge has been heard from within the last 11 minutes (2 poll intervals times 5 minutes plus buffer)
755         def time = now() - (1000 * 60 * 11)
756         bridges.each {
757                 def d = getChildDevice(it.value.mac)
758                 if (d) {
759                         // Set id number on bridge if not done
760                         if (it.value.idNumber == null) {
761                                 it.value.idNumber = getBridgeIdNumber(it.value.serialNumber)
762                                 d.sendEvent(name: "idNumber", value: it.value.idNumber)
763                         }
764
765                         if (it.value.lastActivity < time) { // it.value.lastActivity != null &&
766                                 if (d.currentStatus == "Online") {
767                                         log.warn "$d is Offline"
768                                         d.sendEvent(name: "status", value: "Offline")
769                                         d.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false, isStateChange: true)
770
771                                         Calendar currentTime = Calendar.getInstance()
772                                         getChildDevices().each {
773                                                 def id = getId(it)
774                                                 if (state.bulbs[id]?.online == true) {
775                                                         state.bulbs[id]?.online = false
776                                                         state.bulbs[id]?.unreachableSince = currentTime.getTimeInMillis()
777                                                         it.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false, isStateChange: true)
778                                                 }
779                                         }
780                                 }
781                         } else if (d.currentStatus == "Offline") {
782                                 log.debug "$d is back Online"
783                                 d.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true)
784                                 d.sendEvent(name: "status", value: "Online")//setOnline(false)
785                         }
786                 }
787         }
788 }
789
790 def isValidSource(macAddress) {
791         def vbridges = getVerifiedHueBridges()
792         return (vbridges?.find { "${it.value.mac}" == macAddress }) != null
793 }
794
795 def isInBulbDiscovery() {
796         return state.inBulbDiscovery
797 }
798
799 private updateBulbState(messageBody, hub) {
800         def bulbs = getHueBulbs()
801
802         // Copy of bulbs used to locate old lights in state that are no longer on bridge
803         def toRemove = [:]
804         toRemove << bulbs
805
806         messageBody.each { k, v ->
807
808                 if (v instanceof Map) {
809                         if (bulbs[k] == null) {
810                                 bulbs[k] = [:]
811                         }
812                         bulbs[k] << [id: k, name: v.name, type: v.type, modelid: v.modelid, hub: hub, remove: false]
813                         toRemove.remove(k)
814                 }
815         }
816
817         // Remove bulbs from state that are no longer discovered
818         toRemove.each { k, v ->
819                 log.warn "${bulbs[k].name} no longer exists on bridge, removing"
820                 bulbs.remove(k)
821         }
822 }
823
824 /////////////////////////////////////
825 //CHILD DEVICE METHODS
826 /////////////////////////////////////
827
828 def parse(childDevice, description) {
829         // Update activity timestamp if child device is a valid bridge
830         updateBridgeStatus(childDevice)
831
832         def parsedEvent = parseLanMessage(description)
833         if (parsedEvent.headers && parsedEvent.body) {
834                 def headerString = parsedEvent.headers.toString()
835                 def bodyString = parsedEvent.body.toString()
836                 if (headerString?.contains("json")) {
837                         def body
838                         try {
839                                 body = new groovy.json.JsonSlurper().parseText(bodyString)
840                         } catch (all) {
841                                 log.warn "Parsing Body failed"
842                         }
843                         if (body instanceof java.util.Map) {
844                                 // get (poll) reponse
845                                 return handlePoll(body)
846                         } else {
847                                 //put response
848                                 return handleCommandResponse(body)
849                         }
850                 }
851         } else {
852                 log.debug "parse - got something other than headers,body..."
853                 return []
854         }
855 }
856
857 // Philips Hue priority for color is  xy > ct > hs
858 // For SmartThings, try to always send hue, sat and hex
859 private sendColorEvents(device, xy, hue, sat, ct, colormode = null) {
860         if (device == null || (xy == null && hue == null && sat == null && ct == null))
861                 return
862
863         def events = [:]
864         // For now, only care about changing color temperature if requested by user
865         if (ct != null && ct != 0 && (colormode == "ct" || (xy == null && hue == null && sat == null))) {
866                 // for some reason setting Hue to their specified minimum off 153 yields 154, dealt with below
867                 // 153 (6500K) to 500 (2000K)
868                 def temp = (ct == 154) ? 6500 : Math.round(1000000 / ct)
869                 device.sendEvent([name: "colorTemperature", value: temp, descriptionText: "Color temperature has changed"])
870                 // Return because color temperature change is not counted as a color change in SmartThings so no hex update necessary
871                 return
872         }
873
874         if (hue != null) {
875                 // 0-65535
876                 def value = Math.min(Math.round(hue * 100 / 65535), 65535) as int
877                 events["hue"] = [name: "hue", value: value, descriptionText: "Color has changed", displayed: false]
878         }
879
880         if (sat != null) {
881                 // 0-254
882                 def value = Math.round(sat * 100 / 254) as int
883                 events["saturation"] = [name: "saturation", value: value, descriptionText: "Color has changed", displayed: false]
884         }
885
886         // Following is used to decide what to base hex calculations on since it is preferred to return a colorchange in hex
887         if (xy != null && colormode != "hs") {
888                 // If xy is present and color mode is not specified to hs, pick xy because of priority
889
890                 // [0.0-1.0, 0.0-1.0]
891                 def id = device.deviceNetworkId?.split("/")[1]
892                 def model = state.bulbs[id]?.modelid
893                 def hex = colorFromXY(xy, model)
894
895                 // Create Hue and Saturation events if not previously existing
896                 def hsv = hexToHsv(hex)
897                 if (events["hue"] == null)
898                         events["hue"] = [name: "hue", value: hsv[0], descriptionText: "Color has changed", displayed: false]
899                 if (events["saturation"] == null)
900                         events["saturation"] = [name: "saturation", value: hsv[1], descriptionText: "Color has changed", displayed: false]
901
902                 events["color"] = [name: "color", value: hex.toUpperCase(), descriptionText: "Color has changed", displayed: true]
903         } else if (colormode == "hs" || colormode == null) {
904                 // colormode is "hs" or "xy" is missing, default to follow hue/sat which is already handled above
905                 def hueValue = (hue != null) ? events["hue"].value : Integer.parseInt("$device.currentHue")
906                 def satValue = (sat != null) ? events["saturation"].value : Integer.parseInt("$device.currentSaturation")
907
908
909                 def hex = hsvToHex(hueValue, satValue)
910                 events["color"] = [name: "color", value: hex.toUpperCase(), descriptionText: "Color has changed", displayed: true]
911         }
912
913         boolean sendColorChanged = false
914         events.each {
915                 device.sendEvent(it.value)
916         }
917 }
918
919 private sendBasicEvents(device, param, value) {
920         if (device == null || value == null || param == null)
921                 return
922
923         switch (param) {
924                 case "on":
925                         device.sendEvent(name: "switch", value: (value == true) ? "on" : "off")
926                         break
927                 case "bri":
928                         // 1-254
929                         def level = Math.max(1, Math.round(value * 100 / 254)) as int
930                         device.sendEvent(name: "level", value: level, descriptionText: "Level has changed to ${level}%")
931                         break
932         }
933 }
934
935 /**
936  * Handles a response to a command (PUT) sent to the Hue Bridge.
937  *
938  * Will send appropriate events depending on values changed.
939  *
940  *      Example payload
941  * [,
942  *{"success":{"/lights/5/state/bri":87}},
943  *{"success":{"/lights/5/state/transitiontime":4}},
944  *{"success":{"/lights/5/state/on":true}},
945  *{"success":{"/lights/5/state/xy":[0.4152,0.5336]}},
946  *{"success":{"/lights/5/state/alert":"none"}}* ]
947  *
948  * @param body a data structure of lists and maps based on a JSON data
949  * @return empty array
950  */
951 private handleCommandResponse(body) {
952         // scan entire response before sending events to make sure they are always in the same order
953         def updates = [:]
954
955         body.each { payload ->
956                 if (payload?.success) {
957                         def childDeviceNetworkId = app.id + "/"
958                         def eventType
959                         payload.success.each { k, v ->
960                                 def data = k.split("/")
961                                 if (data.length == 5) {
962                                         childDeviceNetworkId = app.id + "/" + k.split("/")[2]
963                                         if (!updates[childDeviceNetworkId])
964                                                 updates[childDeviceNetworkId] = [:]
965                                         eventType = k.split("/")[4]
966                                         updates[childDeviceNetworkId]."$eventType" = v
967                                 }
968                         }
969                 } else if (payload?.error) {
970                         log.warn "Error returned from Hue bridge, error = ${payload?.error}"
971                         // Check for unauthorized user
972                         if (payload?.error?.type?.value == 1) {
973                                 log.error "Hue username is not valid"
974                                 state.refreshUsernameNeeded = true
975                                 state.username = null
976                         }
977                         return []
978                 }
979         }
980
981         // send events for each update found above (order of events should be same as handlePoll())
982         updates.each { childDeviceNetworkId, params ->
983                 def device = getChildDevice(childDeviceNetworkId)
984                 def id = getId(device)
985                 sendBasicEvents(device, "on", params.on)
986                 sendBasicEvents(device, "bri", params.bri)
987                 sendColorEvents(device, params.xy, params.hue, params.sat, params.ct)
988         }
989         return []
990 }
991
992 /**
993  * Handles a response to a poll (GET) sent to the Hue Bridge.
994  *
995  * Will send appropriate events depending on values changed.
996  *
997  *      Example payload
998  *
999  *{"5":{"state": {"on":true,"bri":102,"hue":25600,"sat":254,"effect":"none","xy":[0.1700,0.7000],"ct":153,"alert":"none",
1000  * "colormode":"xy","reachable":true}, "type": "Extended color light", "name": "Go", "modelid": "LLC020", "manufacturername": "Philips",
1001  * "uniqueid":"00:17:88:01:01:13:d5:11-0b", "swversion": "5.38.1.14378"},
1002  * "6":{"state": {"on":true,"bri":103,"hue":14910,"sat":144,"effect":"none","xy":[0.4596,0.4105],"ct":370,"alert":"none",
1003  * "colormode":"ct","reachable":true}, "type": "Extended color light", "name": "Upstairs Light", "modelid": "LCT007", "manufacturername": "Philips",
1004  * "uniqueid":"00:17:88:01:10:56:ba:2c-0b", "swversion": "5.38.1.14919"},
1005  *
1006  * @param body a data structure of lists and maps based on a JSON data
1007  * @return empty array
1008  */
1009 private handlePoll(body) {
1010         // Used to track "unreachable" time
1011         // Device is considered "offline" if it has been in the "unreachable" state for
1012         // 11 minutes (e.g. two poll intervals)
1013         // Note, Hue Bridge marks devices as "unreachable" often even when they accept commands
1014         Calendar time11 = Calendar.getInstance()
1015         time11.add(Calendar.MINUTE, -11)
1016         Calendar currentTime = Calendar.getInstance()
1017
1018         def bulbs = getChildDevices()
1019         for (bulb in body) {
1020                 def device = bulbs.find { it.deviceNetworkId == "${app.id}/${bulb.key}" }
1021                 if (device) {
1022                         if (bulb.value.state?.reachable) {
1023                                 if (state.bulbs[bulb.key]?.online == false || state.bulbs[bulb.key]?.online == null) {
1024                                         // light just came back online, notify device watch
1025                                         device.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true)
1026                                         log.debug "$device is Online"
1027                                 }
1028                                 // Mark light as "online"
1029                                 state.bulbs[bulb.key]?.unreachableSince = null
1030                                 state.bulbs[bulb.key]?.online = true
1031                         } else {
1032                                 if (state.bulbs[bulb.key]?.unreachableSince == null) {
1033                                         // Store the first time where device was reported as "unreachable"
1034                                         state.bulbs[bulb.key]?.unreachableSince = currentTime.getTimeInMillis()
1035                                 }
1036                                 if (state.bulbs[bulb.key]?.online || state.bulbs[bulb.key]?.online == null) {
1037                                         // Check if device was "unreachable" for more than 11 minutes and mark "offline" if necessary
1038                                         if (state.bulbs[bulb.key]?.unreachableSince < time11.getTimeInMillis() || state.bulbs[bulb.key]?.online == null) {
1039                                                 log.warn "$device went Offline"
1040                                                 state.bulbs[bulb.key]?.online = false
1041                                                 device.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false, isStateChange: true)
1042                                         }
1043                                 }
1044                                 log.warn "$device may not reachable by Hue bridge"
1045                         }
1046                         // If user just executed commands, then do not send events to avoid confusing the turning on/off state
1047                         if (!state.updating) {
1048                                 sendBasicEvents(device, "on", bulb.value?.state?.on)
1049                                 sendBasicEvents(device, "bri", bulb.value?.state?.bri)
1050                                 sendColorEvents(device, bulb.value?.state?.xy, bulb.value?.state?.hue, bulb.value?.state?.sat, bulb.value?.state?.ct, bulb.value?.state?.colormode)
1051                         }
1052                 }
1053         }
1054         return []
1055 }
1056
1057 private updateInProgress() {
1058         state.updating = true
1059         state.lastUpdateStarted = now()
1060         runIn(20, updateHandler)
1061 }
1062
1063 def updateHandler() {
1064         state.updating = false
1065         poll()
1066 }
1067
1068 def hubVerification(bodytext) {
1069         log.trace "Bridge sent back description.xml for verification"
1070         def body = new XmlSlurper().parseText(bodytext)
1071         if (body?.device?.modelName?.text().startsWith("Philips hue bridge")) {
1072                 def bridges = getHueBridges()
1073                 def bridge = bridges.find { it?.key?.contains(body?.device?.UDN?.text()) }
1074                 if (bridge) {
1075                         bridge.value << [name: body?.device?.friendlyName?.text(), serialNumber: body?.device?.serialNumber?.text(), verified: true]
1076                 } else {
1077                         log.error "/description.xml returned a bridge that didn't exist"
1078                 }
1079         }
1080 }
1081
1082 def on(childDevice) {
1083         log.debug "Executing 'on'"
1084         def id = getId(childDevice)
1085         updateInProgress()
1086         createSwitchEvent(childDevice, "on")
1087         put("lights/$id/state", [on: true])
1088         return "Bulb is turning On"
1089 }
1090
1091 def off(childDevice) {
1092         log.debug "Executing 'off'"
1093         def id = getId(childDevice)
1094         updateInProgress()
1095         createSwitchEvent(childDevice, "off")
1096         put("lights/$id/state", [on: false])
1097         return "Bulb is turning Off"
1098 }
1099
1100 def setLevel(childDevice, percent) {
1101         log.debug "Executing 'setLevel'"
1102         def id = getId(childDevice)
1103         updateInProgress()
1104         // 1 - 254
1105         def level
1106         if (percent == 1)
1107                 level = 1
1108         else
1109                 level = Math.min(Math.round(percent * 254 / 100), 254)
1110
1111         createSwitchEvent(childDevice, level > 0, percent)
1112
1113         // For Zigbee lights, if level is set to 0 ST just turns them off without changing level
1114         // that means that the light will still be on when on is called next time
1115         // Lets emulate that here
1116         if (percent > 0) {
1117                 put("lights/$id/state", [bri: level, on: true])
1118         } else {
1119                 put("lights/$id/state", [on: false])
1120         }
1121         return "Setting level to $percent"
1122 }
1123
1124 def setSaturation(childDevice, percent) {
1125         log.debug "Executing 'setSaturation($percent)'"
1126         def id = getId(childDevice)
1127         updateInProgress()
1128         // 0 - 254
1129         def level = Math.min(Math.round(percent * 254 / 100), 254)
1130         // TODO should this be done by app only or should we default to on?
1131         createSwitchEvent(childDevice, "on")
1132         put("lights/$id/state", [sat: level, on: true])
1133         return "Setting saturation to $percent"
1134 }
1135
1136 def setHue(childDevice, percent) {
1137         log.debug "Executing 'setHue($percent)'"
1138         def id = getId(childDevice)
1139         updateInProgress()
1140         // 0 - 65535
1141         def level = Math.min(Math.round(percent * 65535 / 100), 65535)
1142         // TODO should this be done by app only or should we default to on?
1143         createSwitchEvent(childDevice, "on")
1144         put("lights/$id/state", [hue: level, on: true])
1145         return "Setting hue to $percent"
1146 }
1147
1148 def setColorTemperature(childDevice, huesettings) {
1149         log.debug "Executing 'setColorTemperature($huesettings)'"
1150         def id = getId(childDevice)
1151         updateInProgress()
1152         // 153 (6500K) to 500 (2000K)
1153         def ct = hueSettings == 6500 ? 153 : Math.round(1000000 / huesettings)
1154         createSwitchEvent(childDevice, "on")
1155         put("lights/$id/state", [ct: ct, on: true])
1156         return "Setting color temperature to $ct"
1157 }
1158
1159 def setColor(childDevice, huesettings) {
1160         log.debug "Executing 'setColor($huesettings)'"
1161         def id = getId(childDevice)
1162         updateInProgress()
1163
1164         def value = [:]
1165         def hue = null
1166         def sat = null
1167         def xy = null
1168
1169         // Prefer hue/sat over hex to make sure it works with the majority of the smartapps
1170         if (huesettings.hue != null || huesettings.sat != null) {
1171                 // If both hex and hue/sat are set, send all values to bridge to get hue/sat in response from bridge to
1172                 // generate hue/sat events even though bridge will prioritize XY when setting color
1173                 if (huesettings.hue != null)
1174                         value.hue = Math.min(Math.round(huesettings.hue * 65535 / 100), 65535)
1175                 if (huesettings.saturation != null)
1176                         value.sat = Math.min(Math.round(huesettings.saturation * 254 / 100), 254)
1177         } else if (huesettings.hex != null) {
1178                 // For now ignore model to get a consistent color if same color is set across multiple devices
1179                 // def model = state.bulbs[getId(childDevice)]?.modelid
1180                 // value.xy = calculateXY(huesettings.hex, model)
1181                 // Once groups, or scenes are introduced it might be a good idea to use unique models again
1182                 value.xy = calculateXY(huesettings.hex)
1183         }
1184
1185 /* Disabled for now due to bad behavior via Lightning Wizard
1186         if (!value.xy) {
1187                 // Below will translate values to hex->XY to take into account the color support of the different hue types
1188                 def hex = colorUtil.hslToHex((int) huesettings.hue, (int) huesettings.saturation)
1189                 // value.xy = calculateXY(hex, model)
1190                 // Once groups, or scenes are introduced it might be a good idea to use unique models again
1191                 value.xy = calculateXY(hex)
1192         }
1193 */
1194
1195         // Default behavior is to turn light on
1196         value.on = true
1197
1198         if (huesettings.level != null) {
1199                 if (huesettings.level <= 0)
1200                         value.on = false
1201                 else if (huesettings.level == 1)
1202                         value.bri = 1
1203                 else
1204                         value.bri = Math.min(Math.round(huesettings.level * 254 / 100), 254)
1205         }
1206         value.alert = huesettings.alert ? huesettings.alert : "none"
1207         value.transitiontime = huesettings.transitiontime ? huesettings.transitiontime : 4
1208
1209         // Make sure to turn off light if requested
1210         if (huesettings.switch == "off")
1211                 value.on = false
1212
1213         createSwitchEvent(childDevice, value.on ? "on" : "off")
1214         put("lights/$id/state", value)
1215         return "Setting color to $value"
1216 }
1217
1218 private getId(childDevice) {
1219         if (childDevice.device?.deviceNetworkId?.startsWith("HUE")) {
1220                 return childDevice.device?.deviceNetworkId[3..-1]
1221         } else {
1222                 return childDevice.device?.deviceNetworkId.split("/")[-1]
1223         }
1224 }
1225
1226 private poll() {
1227         def host = getBridgeIP()
1228         def uri = "/api/${state.username}/lights/"
1229         log.debug "GET: $host$uri"
1230         sendHubCommand(new physicalgraph.device.HubAction("GET ${uri} HTTP/1.1\r\n" +
1231                         "HOST: ${host}\r\n\r\n", physicalgraph.device.Protocol.LAN, selectedHue))
1232 }
1233
1234 private isOnline(id) {
1235         return (state.bulbs[id]?.online != null && state.bulbs[id]?.online) || state.bulbs[id]?.online == null
1236 }
1237
1238 private put(path, body) {
1239         def host = getBridgeIP()
1240         def uri = "/api/${state.username}/$path"
1241         def bodyJSON = new groovy.json.JsonBuilder(body).toString()
1242         def length = bodyJSON.getBytes().size().toString()
1243
1244         log.debug "PUT:  $host$uri"
1245         log.debug "BODY: ${bodyJSON}"
1246
1247         sendHubCommand(new physicalgraph.device.HubAction("PUT $uri HTTP/1.1\r\n" +
1248                         "HOST: ${host}\r\n" +
1249                         "Content-Length: ${length}\r\n" +
1250                         "\r\n" +
1251                         "${bodyJSON}", physicalgraph.device.Protocol.LAN, "${selectedHue}"))
1252 }
1253
1254 /*
1255  * Bridge serial number from Hue API is in format of 0017882413ad (mac address), however on the actual bridge hardware
1256  * the id is printed as only last six characters so using that to identify bridge to users
1257  */
1258 private getBridgeIdNumber(serialNumber) {
1259         def idNumber = serialNumber ?: ""
1260         if (idNumber?.size() >= 6)
1261                 idNumber = idNumber[-6..-1].toUpperCase()
1262         return idNumber
1263 }
1264
1265 private getBridgeIP() {
1266         def host = null
1267         if (selectedHue) {
1268                 def d = getChildDevice(selectedHue)
1269                 if (d) {
1270                         if (d.getDeviceDataByName("networkAddress"))
1271                                 host = d.getDeviceDataByName("networkAddress")
1272                         else
1273                                 host = d.latestState('networkAddress').stringValue
1274                 }
1275                 if (host == null || host == "") {
1276                         def serialNumber = selectedHue
1277                         def bridge = getHueBridges().find { it?.value?.serialNumber?.equalsIgnoreCase(serialNumber) }?.value
1278                         if (!bridge) {
1279                                 bridge = getHueBridges().find { it?.value?.mac?.equalsIgnoreCase(serialNumber) }?.value
1280                         }
1281                         if (bridge?.ip && bridge?.port) {
1282                                 if (bridge?.ip.contains("."))
1283                                         host = "${bridge?.ip}:${bridge?.port}"
1284                                 else
1285                                         host = "${convertHexToIP(bridge?.ip)}:${convertHexToInt(bridge?.port)}"
1286                         } else if (bridge?.networkAddress && bridge?.deviceAddress)
1287                                 host = "${convertHexToIP(bridge?.networkAddress)}:${convertHexToInt(bridge?.deviceAddress)}"
1288                 }
1289                 log.trace "Bridge: $selectedHue - Host: $host"
1290         }
1291         return host
1292 }
1293
1294 private Integer convertHexToInt(hex) {
1295         Integer.parseInt(hex, 16)
1296 }
1297
1298 def convertBulbListToMap() {
1299         try {
1300                 if (state.bulbs instanceof java.util.List) {
1301                         def map = [:]
1302                         state.bulbs?.unique { it.id }.each { bulb ->
1303                                 map << ["${bulb.id}": ["id": bulb.id, "name": bulb.name, "type": bulb.type, "modelid": bulb.modelid, "hub": bulb.hub, "online": bulb.online]]
1304                         }
1305                         state.bulbs = map
1306                 }
1307         }
1308         catch (Exception e) {
1309                 log.error "Caught error attempting to convert bulb list to map: $e"
1310         }
1311 }
1312
1313 private String convertHexToIP(hex) {
1314         [convertHexToInt(hex[0..1]), convertHexToInt(hex[2..3]), convertHexToInt(hex[4..5]), convertHexToInt(hex[6..7])].join(".")
1315 }
1316
1317 private Boolean hasAllHubsOver(String desiredFirmware) {
1318         return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware }
1319 }
1320
1321 private List getRealHubFirmwareVersions() {
1322         return location.hubs*.firmwareVersionString.findAll { it }
1323 }
1324
1325 /**
1326  * Sends appropriate turningOn/turningOff state events depending on switch or level changes.
1327  *
1328  * @param childDevice device to send event for
1329  * @param setSwitch The new switch state, "on" or "off"
1330  * @param setLevel Optional, switchLevel between 0-100, used if you set level to 0 for example since
1331  *                                that should generate "off" instead of level change
1332  */
1333 private void createSwitchEvent(childDevice, setSwitch, setLevel = null) {
1334
1335         if (setLevel == null) {
1336                 setLevel = childDevice.device?.currentValue("level")
1337         }
1338         // Create on, off, turningOn or turningOff event as necessary
1339         def currentState = childDevice.device?.currentValue("switch")
1340         if ((currentState == "off" || currentState == "turningOff")) {
1341                 if (setSwitch == "on" || setLevel > 0) {
1342                         childDevice.sendEvent(name: "switch", value: "turningOn", displayed: false)
1343                 }
1344         } else if ((currentState == "on" || currentState == "turningOn")) {
1345                 if (setSwitch == "off" || setLevel == 0) {
1346                         childDevice.sendEvent(name: "switch", value: "turningOff", displayed: false)
1347                 }
1348         }
1349 }
1350
1351 /**
1352  * Return the supported color range for different Hue lights. If model is not specified
1353  * it defaults to the smallest Gamut (B) to ensure that colors set on a mix of devices
1354  * will be consistent.
1355  *
1356  * @param model Philips model number, e.g. LCT001
1357  * @return a map with x,y coordinates for red, green and blue according to CIE color space
1358  */
1359 private colorPointsForModel(model = null) {
1360         def result = null
1361         switch (model) {
1362         // Gamut A
1363                 case "LLC001": /* Monet, Renoir, Mondriaan (gen II) */
1364                 case "LLC005": /* Bloom (gen II) */
1365                 case "LLC006": /* Iris (gen III) */
1366                 case "LLC007": /* Bloom, Aura (gen III) */
1367                 case "LLC011": /* Hue Bloom */
1368                 case "LLC012": /* Hue Bloom */
1369                 case "LLC013": /* Storylight */
1370                 case "LST001": /* Light Strips */
1371                 case "LLC010": /* Hue Living Colors Iris + */
1372                         result = [r: [x: 0.704f, y: 0.296f], g: [x: 0.2151f, y: 0.7106f], b: [x: 0.138f, y: 0.08f]];
1373                         break
1374         // Gamut C
1375                 case "LLC020": /* Hue Go */
1376                 case "LST002": /* Hue LightStrips Plus */
1377                         result = [r: [x: 0.692f, y: 0.308f], g: [x: 0.17f, y: 0.7f], b: [x: 0.153f, y: 0.048f]];
1378                         break
1379         // Gamut B
1380                 case "LCT001": /* Hue A19 */
1381                 case "LCT002": /* Hue BR30 */
1382                 case "LCT003": /* Hue GU10 */
1383                 case "LCT007": /* Hue A19 + */
1384                 case "LLM001": /* Color Light Module + */
1385                 default:
1386                         result = [r: [x: 0.675f, y: 0.322f], g: [x: 0.4091f, y: 0.518f], b: [x: 0.167f, y: 0.04f]];
1387
1388         }
1389         return result;
1390 }
1391
1392 /**
1393  * Return x, y  value from android color and light model Id.
1394  * Please note: This method does not incorporate brightness values. Get/set it from/to your light seperately.
1395  *
1396  * From Philips Hue SDK modified for Groovy
1397  *
1398  * @param color the color value in hex like // #ffa013
1399  * @param model the model Id of Light (or null to get default Gamut B)
1400  * @return the float array of length 2, where index 0 and 1 gives  x and y values respectively.
1401  */
1402 private float[] calculateXY(colorStr, model = null) {
1403
1404         // #ffa013
1405         def cred = Integer.valueOf(colorStr.substring(1, 3), 16)
1406         def cgreen = Integer.valueOf(colorStr.substring(3, 5), 16)
1407         def cblue = Integer.valueOf(colorStr.substring(5, 7), 16)
1408
1409         float red = cred / 255.0f;
1410         float green = cgreen / 255.0f;
1411         float blue = cblue / 255.0f;
1412
1413         // Wide gamut conversion D65
1414         float r = ((red > 0.04045f) ? (float) Math.pow((red + 0.055f) / (1.0f + 0.055f), 2.4f) : (red / 12.92f));
1415         float g = (green > 0.04045f) ? (float) Math.pow((green + 0.055f) / (1.0f + 0.055f), 2.4f) : (green / 12.92f);
1416         float b = (blue > 0.04045f) ? (float) Math.pow((blue + 0.055f) / (1.0f + 0.055f), 2.4f) : (blue / 12.92f);
1417
1418         // Why values are different in ios and android , IOS is considered
1419         // Modified conversion from RGB -> XYZ with better results on colors for
1420         // the lights
1421         float x = r * 0.664511f + g * 0.154324f + b * 0.162028f;
1422         float y = r * 0.283881f + g * 0.668433f + b * 0.047685f;
1423         float z = r * 0.000088f + g * 0.072310f + b * 0.986039f;
1424
1425         float[] xy = new float[2];
1426
1427         xy[0] = (x / (x + y + z));
1428         xy[1] = (y / (x + y + z));
1429         if (Float.isNaN(xy[0])) {
1430                 xy[0] = 0.0f;
1431         }
1432         if (Float.isNaN(xy[1])) {
1433                 xy[1] = 0.0f;
1434         }
1435         /*if(true)
1436                 return [0.0f,0.0f]*/
1437
1438         // Check if the given XY value is within the colourreach of our lamps.
1439         def xyPoint = [x: xy[0], y: xy[1]];
1440         def colorPoints = colorPointsForModel(model);
1441         boolean inReachOfLamps = checkPointInLampsReach(xyPoint, colorPoints);
1442         if (!inReachOfLamps) {
1443                 // It seems the colour is out of reach
1444                 // let's find the closes colour we can produce with our lamp and
1445                 // send this XY value out.
1446
1447                 // Find the closest point on each line in the triangle.
1448                 def pAB = getClosestPointToPoints(colorPoints.r, colorPoints.g, xyPoint);
1449                 def pAC = getClosestPointToPoints(colorPoints.b, colorPoints.r, xyPoint);
1450                 def pBC = getClosestPointToPoints(colorPoints.g, colorPoints.b, xyPoint);
1451
1452                 // Get the distances per point and see which point is closer to our
1453                 // Point.
1454                 float dAB = getDistanceBetweenTwoPoints(xyPoint, pAB);
1455                 float dAC = getDistanceBetweenTwoPoints(xyPoint, pAC);
1456                 float dBC = getDistanceBetweenTwoPoints(xyPoint, pBC);
1457
1458                 float lowest = dAB;
1459                 def closestPoint = pAB;
1460                 if (dAC < lowest) {
1461                         lowest = dAC;
1462                         closestPoint = pAC;
1463                 }
1464                 if (dBC < lowest) {
1465                         lowest = dBC;
1466                         closestPoint = pBC;
1467                 }
1468
1469                 // Change the xy value to a value which is within the reach of the
1470                 // lamp.
1471                 xy[0] = closestPoint.x;
1472                 xy[1] = closestPoint.y;
1473         }
1474         //              xy[0] = PHHueHelper.precision(4, xy[0]);
1475         //              xy[1] = PHHueHelper.precision(4, xy[1]);
1476
1477         // TODO needed, assume it just sets number of decimals?
1478         //xy[0] = PHHueHelper.precision(xy[0]);
1479         //xy[1] = PHHueHelper.precision(xy[1]);
1480         return xy;
1481 }
1482
1483 /**
1484  * Generates the color for the given XY values and light model.  Model can be null if it is not known.
1485  * Note: When the exact values cannot be represented, it will return the closest match.
1486  * Note 2: This method does not incorporate brightness values. Get/Set it from/to your light seperately.
1487  *
1488  * From Philips Hue SDK modified for Groovy
1489  *
1490  * @param points the float array contain x and the y value. [x,y]
1491  * @param model the model of the lamp, example: "LCT001" for hue bulb. Used to calculate the color gamut.
1492  *              If this value is empty the default gamut values are used.
1493  * @return the color value in hex (#ff03d3). If xy is null OR xy is not an array of size 2, Color. BLACK will be returned
1494  */
1495 private String colorFromXY(points, model) {
1496
1497         if (points == null || model == null) {
1498                 log.warn "Input color missing"
1499                 return "#000000"
1500         }
1501
1502         def xy = [x: points[0], y: points[1]];
1503
1504         def colorPoints = colorPointsForModel(model);
1505         boolean inReachOfLamps = checkPointInLampsReach(xy, colorPoints);
1506
1507         if (!inReachOfLamps) {
1508                 // It seems the colour is out of reach
1509                 // let's find the closest colour we can produce with our lamp and
1510                 // send this XY value out.
1511                 // Find the closest point on each line in the triangle.
1512                 def pAB = getClosestPointToPoints(colorPoints.r, colorPoints.g, xy);
1513                 def pAC = getClosestPointToPoints(colorPoints.b, colorPoints.r, xy);
1514                 def pBC = getClosestPointToPoints(colorPoints.g, colorPoints.b, xy);
1515
1516                 // Get the distances per point and see which point is closer to our
1517                 // Point.
1518                 float dAB = getDistanceBetweenTwoPoints(xy, pAB);
1519                 float dAC = getDistanceBetweenTwoPoints(xy, pAC);
1520                 float dBC = getDistanceBetweenTwoPoints(xy, pBC);
1521                 float lowest = dAB;
1522                 def closestPoint = pAB;
1523                 if (dAC < lowest) {
1524                         lowest = dAC;
1525                         closestPoint = pAC;
1526                 }
1527                 if (dBC < lowest) {
1528                         lowest = dBC;
1529                         closestPoint = pBC;
1530                 }
1531                 // Change the xy value to a value which is within the reach of the
1532                 // lamp.
1533                 xy.x = closestPoint.x;
1534                 xy.y = closestPoint.y;
1535         }
1536         float x = xy.x;
1537         float y = xy.y;
1538         float z = 1.0f - x - y;
1539         float y2 = 1.0f;
1540         float x2 = (y2 / y) * x;
1541         float z2 = (y2 / y) * z;
1542         /*
1543          * // Wide gamut conversion float r = X * 1.612f - Y * 0.203f - Z *
1544          * 0.302f; float g = -X * 0.509f + Y * 1.412f + Z * 0.066f; float b = X
1545          * * 0.026f - Y * 0.072f + Z * 0.962f;
1546          */
1547         // sRGB conversion
1548         // float r = X * 3.2410f - Y * 1.5374f - Z * 0.4986f;
1549         // float g = -X * 0.9692f + Y * 1.8760f + Z * 0.0416f;
1550         // float b = X * 0.0556f - Y * 0.2040f + Z * 1.0570f;
1551
1552         // sRGB D65 conversion
1553         float r = x2 * 1.656492f - y2 * 0.354851f - z2 * 0.255038f;
1554         float g = -x2 * 0.707196f + y2 * 1.655397f + z2 * 0.036152f;
1555         float b = x2 * 0.051713f - y2 * 0.121364f + z2 * 1.011530f;
1556
1557         if (r > b && r > g && r > 1.0f) {
1558                 // red is too big
1559                 g = g / r;
1560                 b = b / r;
1561                 r = 1.0f;
1562         } else if (g > b && g > r && g > 1.0f) {
1563                 // green is too big
1564                 r = r / g;
1565                 b = b / g;
1566                 g = 1.0f;
1567         } else if (b > r && b > g && b > 1.0f) {
1568                 // blue is too big
1569                 r = r / b;
1570                 g = g / b;
1571                 b = 1.0f;
1572         }
1573         // Apply gamma correction
1574         r = r <= 0.0031308f ? 12.92f * r : (1.0f + 0.055f) * (float) Math.pow(r, (1.0f / 2.4f)) - 0.055f;
1575         g = g <= 0.0031308f ? 12.92f * g : (1.0f + 0.055f) * (float) Math.pow(g, (1.0f / 2.4f)) - 0.055f;
1576         b = b <= 0.0031308f ? 12.92f * b : (1.0f + 0.055f) * (float) Math.pow(b, (1.0f / 2.4f)) - 0.055f;
1577
1578         if (r > b && r > g) {
1579                 // red is biggest
1580                 if (r > 1.0f) {
1581                         g = g / r;
1582                         b = b / r;
1583                         r = 1.0f;
1584                 }
1585         } else if (g > b && g > r) {
1586                 // green is biggest
1587                 if (g > 1.0f) {
1588                         r = r / g;
1589                         b = b / g;
1590                         g = 1.0f;
1591                 }
1592         } else if (b > r && b > g && b > 1.0f) {
1593                 r = r / b;
1594                 g = g / b;
1595                 b = 1.0f;
1596         }
1597
1598         // neglecting if the value is negative.
1599         if (r < 0.0f) {
1600                 r = 0.0f;
1601         }
1602         if (g < 0.0f) {
1603                 g = 0.0f;
1604         }
1605         if (b < 0.0f) {
1606                 b = 0.0f;
1607         }
1608
1609         // Converting float components to int components.
1610         def r1 = String.format("%02X", (int) (r * 255.0f));
1611         def g1 = String.format("%02X", (int) (g * 255.0f));
1612         def b1 = String.format("%02X", (int) (b * 255.0f));
1613
1614         return "#$r1$g1$b1"
1615 }
1616
1617 /**
1618  * Calculates crossProduct of two 2D vectors / points.
1619  *
1620  * From Philips Hue SDK modified for Groovy
1621  *
1622  * @param p1 first point used as vector [x: 0.0f, y: 0.0f]
1623  * @param p2 second point used as vector [x: 0.0f, y: 0.0f]
1624  * @return crossProduct of vectors
1625  */
1626 private float crossProduct(p1, p2) {
1627         return (p1.x * p2.y - p1.y * p2.x);
1628 }
1629
1630 /**
1631  * Find the closest point on a line.
1632  * This point will be within reach of the lamp.
1633  *
1634  * From Philips Hue SDK modified for Groovy
1635  *
1636  * @param A the point where the line starts [x:..,y:..]
1637  * @param B the point where the line ends [x:..,y:..]
1638  * @param P the point which is close to a line. [x:..,y:..]
1639  * @return the point which is on the line. [x:..,y:..]
1640  */
1641 private getClosestPointToPoints(A, B, P) {
1642         def AP = [x: (P.x - A.x), y: (P.y - A.y)];
1643         def AB = [x: (B.x - A.x), y: (B.y - A.y)];
1644
1645         float ab2 = AB.x * AB.x + AB.y * AB.y;
1646         float ap_ab = AP.x * AB.x + AP.y * AB.y;
1647
1648         float t = ap_ab / ab2;
1649
1650         if (t < 0.0f)
1651                 t = 0.0f;
1652         else if (t > 1.0f)
1653                 t = 1.0f;
1654
1655         def newPoint = [x: (A.x + AB.x * t), y: (A.y + AB.y * t)];
1656         return newPoint;
1657 }
1658
1659 /**
1660  * Find the distance between two points.
1661  *
1662  * From Philips Hue SDK modified for Groovy
1663  *
1664  * @param one [x:..,y:..]
1665  * @param two [x:..,y:..]
1666  * @return the distance between point one and two
1667  */
1668 private float getDistanceBetweenTwoPoints(one, two) {
1669         float dx = one.x - two.x; // horizontal difference
1670         float dy = one.y - two.y; // vertical difference
1671         float dist = Math.sqrt(dx * dx + dy * dy);
1672
1673         return dist;
1674 }
1675
1676 /**
1677  * Method to see if the given XY value is within the reach of the lamps.
1678  *
1679  * From Philips Hue SDK modified for Groovy
1680  *
1681  * @param p the point containing the X,Y value [x:..,y:..]
1682  * @return true if within reach, false otherwise.
1683  */
1684 private boolean checkPointInLampsReach(p, colorPoints) {
1685
1686         def red = colorPoints.r;
1687         def green = colorPoints.g;
1688         def blue = colorPoints.b;
1689
1690         def v1 = [x: (green.x - red.x), y: (green.y - red.y)];
1691         def v2 = [x: (blue.x - red.x), y: (blue.y - red.y)];
1692
1693         def q = [x: (p.x - red.x), y: (p.y - red.y)];
1694
1695         float s = crossProduct(q, v2) / crossProduct(v1, v2);
1696         float t = crossProduct(v1, q) / crossProduct(v1, v2);
1697
1698         if ((s >= 0.0f) && (t >= 0.0f) && (s + t <= 1.0f)) {
1699                 return true;
1700         } else {
1701                 return false;
1702         }
1703 }
1704
1705 /**
1706  * Converts an RGB color in hex to HSV/HSB.
1707  * Algorithm based on http://en.wikipedia.org/wiki/HSV_color_space.
1708  *
1709  * @param colorStr color value in hex (#ff03d3)
1710  *
1711  * @return HSV representation in an array (0-100) [hue, sat, value]
1712  */
1713 def hexToHsv(colorStr) {
1714         def r = Integer.valueOf(colorStr.substring(1, 3), 16) / 255
1715         def g = Integer.valueOf(colorStr.substring(3, 5), 16) / 255
1716         def b = Integer.valueOf(colorStr.substring(5, 7), 16) / 255
1717
1718         def max = Math.max(Math.max(r, g), b)
1719         def min = Math.min(Math.min(r, g), b)
1720
1721         def h, s, v = max
1722
1723         def d = max - min
1724         s = max == 0 ? 0 : d / max
1725
1726         if (max == min) {
1727                 h = 0
1728         } else {
1729                 switch (max) {
1730                         case r: h = (g - b) / d + (g < b ? 6 : 0); break
1731                         case g: h = (b - r) / d + 2; break
1732                         case b: h = (r - g) / d + 4; break
1733                 }
1734                 h /= 6;
1735         }
1736
1737         return [Math.round(h * 100), Math.round(s * 100), Math.round(v * 100)]
1738 }
1739
1740 /**
1741  * Converts HSV/HSB color to RGB in hex.
1742  * Algorithm based on http://en.wikipedia.org/wiki/HSV_color_space.
1743  *
1744  * @param hue hue 0-100
1745  * @param sat saturation 0-100
1746  * @param value value 0-100 (defaults to 100)
1747
1748  * @return the color in hex (#ff03d3)
1749  */
1750 def hsvToHex(hue, sat, value = 100) {
1751         def r, g, b;
1752         def h = hue / 100
1753         def s = sat / 100
1754         def v = value / 100
1755
1756         def i = Math.floor(h * 6)
1757         def f = h * 6 - i
1758         def p = v * (1 - s)
1759         def q = v * (1 - f * s)
1760         def t = v * (1 - (1 - f) * s)
1761
1762         switch (i % 6) {
1763                 case 0:
1764                         r = v
1765                         g = t
1766                         b = p
1767                         break
1768                 case 1:
1769                         r = q
1770                         g = v
1771                         b = p
1772                         break
1773                 case 2:
1774                         r = p
1775                         g = v
1776                         b = t
1777                         break
1778                 case 3:
1779                         r = p
1780                         g = q
1781                         b = v
1782                         break
1783                 case 4:
1784                         r = t
1785                         g = p
1786                         b = v
1787                         break
1788                 case 5:
1789                         r = v
1790                         g = p
1791                         b = q
1792                         break
1793         }
1794
1795         // Converting float components to int components.
1796         def r1 = String.format("%02X", (int) (r * 255.0f))
1797         def g1 = String.format("%02X", (int) (g * 255.0f))
1798         def b1 = String.format("%02X", (int) (b * 255.0f))
1799
1800         return "#$r1$g1$b1"
1801 }