forked from markbergsma/pimatic-hue-zll
-
Notifications
You must be signed in to change notification settings - Fork 0
/
hue.coffee
409 lines (345 loc) · 13.5 KB
/
hue.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# Hue API specific classes and functions
module.exports = (env) ->
# Require the bluebird promise library
Promise = env.require 'bluebird'
# Require the [cassert library](https://github.com/rhoot/cassert).
assert = env.require 'cassert'
# node-hue-api needs es6-promise
es6Promise = require 'es6-promise'
hueapi = require 'node-hue-api'
Queue = require 'simple-promise-queue'
es6PromiseRetry = require 'promise-retry'
Queue.setPromise(Promise)
class HueQueue extends Queue
@defaultErrorFunction: (error, number, retries, retryFunction, descr) =>
error.message = "Error during #{descr} Hue API request (attempt #{number}/#{retries+1}): " + error.message
if not error.code? then throw error # Only retry for system errors
switch error.code
when 'ECONNRESET'
error.message += " (connection reset)"
if number < retries + 1
env.logger.debug error.message
retryFunction(error) # Throws an error
constructor: (options) ->
super(options)
@maxLength = options.maxLength or Infinity
@bindObject = options.bindObject
pushTask: (promiseFunction) ->
if @length < @maxLength
return super(promiseFunction)
else
return Promise.reject Error("Hue API maximum queue length (#{@maxLength}) exceeded")
pushRequest: (request, args...) ->
return @pushTask(
(resolve, reject) =>
request.bind(@bindObject)(args..., (err, result) =>
if err
reject err
else
resolve result
)
)
retryRequest: (request, args=[], retryOptions={}) ->
assert request instanceof Function
assert Array.isArray(args)
retries = retryOptions.retries or @defaultRetries
errorFunction = retryOptions.errorFunction or HueQueue.defaultErrorFunction
descr = retryOptions.descr or "a"
assert errorFunction instanceof Function
return promiseRetry(
( (retryFunction, number) =>
@pushRequest(
request, args...
).catch( (error) =>
errorFunction error, number, retries, retryFunction, descr
)
),
retries: retries
)
# Convert ES6 Promise to Bluebird
promiseRetry = (args...) => Promise.resolve es6PromiseRetry args...
initHueApi = (config) ->
hueApi = hueapi.HueApi(
config.host,
config.username,
config.timeout,
config.port
)
BaseHueDevice.initHueQueue(config, hueApi)
BaseHueDevice.bridgeVersion(hueApi) if config.username?.length > 0
return hueApi
searchBridge = (timeout=5000) ->
hueapi.nupnpSearch().catch( (error) =>
hueapi.upnpSearch(timeout)
).then( (result) =>
env.logger.debug "Hue bridges:", result
if result.length is 0
return Promise.reject Error("No Hue bridges found.")
if result.length > 1
error.logger.warn "Found #{result.length} Hue bridges, but only 1 is supported. Picking the first one found."
return result[0].ipaddress
).catch( (error) =>
return Promise.reject Error("Could not find Hue bridge: " + error.message)
)
registerUser = (hueApi, hostname, userDescription, timeout=30000) =>
interval = 3000
return promiseRetry(
( (retry, number) =>
hueApi.registerUser(hostname, userDescription).catch(retry)
),
{ retries: timeout / interval, factor: 1, minTimeout: interval, maxTimeout: interval }
)
class BaseHueDevice
@hueQ: new HueQueue({
maxLength: 4 # Incremented for each additional device
autoStart: true
})
@initHueQueue: (config, hueApi) ->
BaseHueDevice.hueQ.concurrency = config.hueApiConcurrency
BaseHueDevice.hueQ.maxLength = config.hueApiQueueMaxLength if config.hueApiQueueMaxLength > 0
BaseHueDevice.hueQ.timeout = config.timeout
BaseHueDevice.hueQ.defaultRetries = config.retries
BaseHueDevice.hueQ.bindObject = hueApi
@bridgeVersion: (hueApi) ->
BaseHueDevice.hueQ.retryRequest(
hueApi.version,
[],
retries: 2,
descr: "bridge version"
).then( (version) =>
env.logger.info "Connected to bridge #{version['name']}, " +
"API version #{version['version']['api']}, software #{version['version']['software']}"
).catch( (error) =>
env.logger.error "Error while attempting to retrieve the Hue bridge version:", error.message
)
constructor: (@device, @plugin) ->
@_destroyed = false
if @plugin.config.hueApiQueueMaxLength is 0
BaseHueDevice.hueQ.maxLength++
destroy: () ->
@_destroyed = true
class BaseHueLight extends BaseHueDevice
devDescr: "light"
@globalPolling: null
@statusCallbacks: {}
# Static methods for polling all lights
@discover: (hueApi) ->
return BaseHueDevice.hueQ.retryRequest(
hueApi.lights, [],
descr: "lights inventory"
).catch( (error) =>
env.logger.error "Error while retrieving inventory of all lights:", error.message
)
@allLightsReceived: (lightsResult) ->
for light in lightsResult.lights
if Array.isArray(BaseHueLight.statusCallbacks[light.id])
cb(light) for cb in BaseHueLight.statusCallbacks[light.id]
constructor: (@device, @plugin, @hueId) ->
super(@device, @plugin)
@pendingStateChange = null
@lightStatusResult =
state: {}
@deviceStateCallback = null
@registerStatusHandler(@_statusReceived)
destroy: () ->
@deregisterStatusHandler(@_statusReceived)
super()
registerStatusHandler: (callback, hueId=@hueId) ->
if Array.isArray(@constructor.statusCallbacks[hueId])
@constructor.statusCallbacks[hueId].push(callback)
else
@constructor.statusCallbacks[hueId] = Array(callback)
deregisterStatusHandler: (callback, hueId=@hueId) ->
@constructor.statusCallbacks[hueId] = (cb for cb in @constructor.statusCallbacks when cb isnt callback)
setupGlobalPolling: (interval, retries) ->
repeatPoll = () =>
firstPoll = @pollAllLights(retries)
firstPoll.delay(interval).finally( =>
repeatPoll()
return null
)
return firstPoll
return BaseHueLight.globalPolling or
BaseHueLight.globalPolling = repeatPoll()
setupPolling: (interval, retries) =>
repeatPoll = () =>
firstPoll = @poll(retries)
firstPoll.delay(interval).finally( =>
repeatPoll() unless @_destroyed
return null
)
return firstPoll
return if interval > 0 then repeatPoll() else @poll(retries)
pollAllLights: ([email protected]) =>
return BaseHueDevice.hueQ.retryRequest(@plugin.hueApi.lights, [],
retries: retries,
descr: "poll of all lights"
).then(
BaseHueLight.allLightsReceived
).catch( (error) =>
env.logger.error error.message
)
poll: ([email protected]) =>
return BaseHueDevice.hueQ.retryRequest(@plugin.hueApi.lightStatus, [@hueId],
retries: retries,
descr: "poll of light #{@hueId}"
).then(
@_statusReceived
).catch( (error) =>
env.logger.error "Error while polling light #{@hueId} status:", error.message
)
_diffState: (newRState) ->
lstate = @lightStatusResult?.state or {}
diff = {}
diff[k] = v for k, v of newRState when (
not lstate[k]? or
(k == 'xy' and ((v[0] != lstate['xy'][0]) or (v[1] != lstate['xy'][1]))) or
(k != 'xy' and lstate[k] != v))
return diff
_statusReceived: (result) =>
if result.state?
diff = @_diffState(result.state)
if Object.keys(diff).length > 0
env.logger.debug "Received #{@devDescr} #{@hueId} state change:", JSON.stringify(diff)
@lightStatusResult = result
@name = result.name if result.name?
@type = result.type if result.type?
@deviceStateCallback?(result.state) if result.state?
return result.state or Promise.reject(Error("Missing state object in light status result"))
_mergeStateChange: (stateChange) ->
@lightStatusResult.state[k] = v for k, v of stateChange.payload()
@lightStatusResult.state
createStateChange: (json) -> hueapi.lightState.create json
prepareStateChange: ->
@pendingStateChange = @createStateChange()
@pendingStateChange.transition(@device.config.transitionTime) if @device.config.transitionTime?
return @pendingStateChange
getLightStatus: -> @lightStatusResult
_hueStateChangeFunction: -> @plugin.hueApi.setLightState
changeHueState: (hueStateChange) ->
retryHueStateChange = (remainingRetries) =>
return BaseHueDevice.hueQ.pushRequest(
@_hueStateChangeFunction(), @hueId, hueStateChange
).catch(
(error) =>
switch error.code
when 'ECONNRESET'
repeat = yes
error.message += " (connection reset)"
else
repeat = no
error.message = "Error while changing #{@devDescr} state: " + error.message
if repeat and remainingRetries > 0
env.logger.debug error.message
env.logger.debug """
Retrying (#{remainingRetries} more) Hue API #{@devDescr} state change request for hue id #{@hueId}
"""
return retryHueStateChange(remainingRetries - 1)
else
return Promise.reject error
)
return retryHueStateChange(@plugin.config.retries).then( =>
env.logger.debug "Changing #{@devDescr} #{@hueId} state:", JSON.stringify(hueStateChange.payload())
@_mergeStateChange hueStateChange
).finally( =>
@pendingStateChange = null # Start with a clean state
)
class BaseHueLightGroup extends BaseHueLight
devDescr: "group"
@globalPolling: null
@statusCallbacks: {}
# Static methods for polling all lights
@discover: (hueApi) ->
return BaseHueDevice.hueQ.retryRequest(
hueApi.groups, [],
descr: "groups inventory"
).catch( (error) =>
env.logger.error "Error while retrieving inventory of all light groups:", error.message
)
@allGroupsReceived: (groupsResult) ->
for group in groupsResult
if Array.isArray(BaseHueLightGroup.statusCallbacks[group.id])
cb(group) for cb in BaseHueLightGroup.statusCallbacks[group.id]
setupGlobalPolling: (interval, retries) ->
repeatPoll = () =>
firstPoll = @pollAllGroups(retries)
firstPoll.delay(interval).finally( =>
repeatPoll()
return null
)
return firstPoll
return BaseHueLightGroup.globalPolling or
BaseHueLightGroup.globalPolling = repeatPoll()
pollAllGroups: ([email protected]) =>
return BaseHueDevice.hueQ.retryRequest(@plugin.hueApi.groups, [],
retries: retries,
descr: "poll of all light groups"
).then(
BaseHueLightGroup.allGroupsReceived
).catch( (error) =>
env.logger.error error.message
)
poll: ([email protected]) =>
return BaseHueDevice.hueQ.retryRequest(@plugin.hueApi.getGroup, [@hueId],
retries: retries,
descr: "poll of group #{@hueId}"
).then(
@_statusReceived
).catch( (error) =>
env.logger.error "Error while polling light group #{@hueId} status:", error.message
)
_statusReceived: (result) =>
# Light groups don't have a .state object, but a .lastAction or .action instead
result.state = result.lastAction or result.action
delete result.lastAction if result.lastAction?
delete result.action if result.action?
return super(result)
_hueStateChangeFunction: -> @plugin.hueApi.setGroupLightState
class BaseHueScenes extends BaseHueDevice
constructor: (@device, @plugin) ->
super(@device, @plugin)
@scenesByName = {}
@scenesPromise = null
requestScenes: (retries) ->
return @scenesPromise = BaseHueDevice.hueQ.retryRequest(
@plugin.hueApi.scenes, [],
descr: "scenes",
retries: retries
).then(
@_scenesReceived
)
_scenesReceived: (result) =>
nameRegex = /^(.+) (on|off) (\d+)$/
for scene in result
try
tokens = scene.name.match(nameRegex)
scene.uniquename = if tokens? then tokens[1] else scene.name
scene.lastupdatedts = Date.parse(scene.lastupdated) or 0
lcname = scene.uniquename.toLowerCase()
@scenesByName[lcname] = scene unless scene.lastupdatedts < @scenesByName[lcname]?.lastupdatedts
catch error
env.logger.error error.message
_lookupSceneByName: (sceneName) => Promise.join @scenesPromise, ( => @scenesByName[sceneName.toLowerCase()] )
activateSceneByName: (sceneName, groupId=null) ->
return @_lookupSceneByName(sceneName).then( (scene) =>
if scene? and scene.id?
return BaseHueLightGroup.hueQ.retryRequest(
@plugin.hueApi.activateScene, [scene.id, groupId],
descr: "scene activation"
).then( =>
env.logger.debug "Activating Hue scene id: #{scene.id} name: \"#{sceneName}\"" + \
if groupId? then " group: #{groupId}" else ""
)
else
return Promise.reject(Error("Scene with name #{sceneName} not found"))
)
return exports = {
initHueApi,
searchBridge,
registerUser,
HueQueue,
BaseHueDevice,
BaseHueLight,
BaseHueLightGroup,
BaseHueScenes
}