pimatic-hue-zll
Version:
Pimatic integration with (Zigbee Light Link based) Philips Hue
409 lines (345 loc) • 13.8 kB
text/coffeescript
# 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: (retries=@plugin.config.retries) =>
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: (retries=@plugin.config.retries) =>
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: (retries=@plugin.config.retries) =>
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: (retries=@plugin.config.retries) =>
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
}